diff --git a/.ai/lessons.md b/.ai/lessons.md index a505578511..339fc7e60d 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -124,6 +124,7 @@ ## Cached and stored state - `StandaloneDocker`/`SwarmDocker` `server` uses `Server::findCached()`, a per-process identity map that is flushed only when the `servers` row updates. A `ServerSetting` save does not flush it, so pass the current `Server` instance (`setRelation('server', $server)`) when generated output depends on just-saved settings. - Application deployments use the stored `custom_labels` when they exist. When a server-level setting changes the generated labels, regenerate the stored labels of read-only (`is_container_label_readonly_enabled`) applications, or a redeploy keeps the old labels. +- Tailwind `text-base` is a colour utility here (`--color-base`, near-black), not a font size. Use `text-[16px]` for size. ## Keep browser form restore away from app state - Firefox session restore writes saved input values back and fires a trusted `input` event. Add `autocomplete="off"` to inputs that only mirror client state (for example an Alpine-bound color picker), or their handlers run without a user action. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 6463b70cdf..86545e010f 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -81,7 +81,7 @@ class StartClickhouse data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 72f66a64ce..2602de63d1 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -130,7 +130,7 @@ class StartDragonfly } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 75827b4bac..dddb86e2c2 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -132,7 +132,7 @@ class StartKeydb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 5440ed8611..234583a538 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -123,7 +123,7 @@ class StartMariadb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 49cee8ea3f..0064b43e38 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -130,7 +130,7 @@ class StartMongodb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 6fb288b7b0..e8c24d5179 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -125,7 +125,7 @@ class StartMysql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 05f9be02f9..88bb59a88b 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -131,7 +131,7 @@ class StartPostgresql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 3d447d91da..b0ac2e7001 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -133,7 +133,7 @@ class StartRedis } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index b85d4e73e3..b63598f998 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -69,7 +69,7 @@ class StartSqlite data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($persistent_storages) > 0) { $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 11bf160f57..4fce8b7168 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -23,9 +23,17 @@ class StartLogDrain $type = 'axiom'; } elseif ($server->settings->is_logdrain_custom_enabled) { $type = 'custom'; + } elseif ($server->settings->is_logdrain_cloudwatch_enabled) { + $type = 'cloudwatch'; } else { $type = 'none'; } + if ($type === 'cloudwatch') { + // Containers ship logs directly via Docker's awslogs driver; no Fluent Bit container is needed. + StopLogDrain::run($server); + + return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.'; + } if ($type !== 'none') { StopLogDrain::run($server); } @@ -230,7 +238,7 @@ Files: private function logDrainNetworkConnectCommands(Server $server): array { - if (! $server->isLogDrainEnabled()) { + if (! $server->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 4edeb9f9dd..6d938c9ac4 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -140,7 +140,7 @@ class StartService return []; } - if (! $service->destination?->server?->isLogDrainEnabled()) { + if (! $service->destination?->server?->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index 1cca69a663..fe38dbd1d5 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -45,6 +45,7 @@ class NotificationsController extends Controller 'use_instance_email_settings' => 'sometimes|boolean', 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', + 'deployment_commit_details_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean', 'restart_limit_reached_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean', diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index 8084070b82..af5aaa1dfd 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -23,6 +23,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -44,6 +48,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled, 'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name, 'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled, + 'is_logdrain_cloudwatch_enabled' => (bool) $settings->is_logdrain_cloudwatch_enabled, + 'logdrain_cloudwatch_region' => $settings->logdrain_cloudwatch_region, + 'logdrain_cloudwatch_group' => $settings->logdrain_cloudwatch_group, + 'logdrain_cloudwatch_stream_prefix' => $settings->logdrain_cloudwatch_stream_prefix, ]; if ($this->canReadSensitive()) { @@ -81,6 +89,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -130,6 +142,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -172,6 +188,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled' => 'boolean', 'logdrain_custom_config' => ['nullable', 'string', new ValidFluentBitConfig], 'logdrain_custom_config_parser' => ['nullable', 'string', new ValidFluentBitConfig(parsersOnly: true)], + 'is_logdrain_cloudwatch_enabled' => 'boolean', + 'logdrain_cloudwatch_region' => ['nullable', 'string', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logdrain_cloudwatch_group' => ['nullable', 'string', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logdrain_cloudwatch_stream_prefix' => ['nullable', 'string', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); @@ -229,6 +249,27 @@ class ServerLogDrainsController extends Controller ], 422); } + if ($settings->is_logdrain_cloudwatch_enabled) { + if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled || $settings->is_logdrain_highlight_enabled) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_logdrain_cloudwatch_enabled' => ['Amazon CloudWatch Logs cannot be enabled together with another log drain.'], + ], + ], 422); + } + $errors = []; + if (blank($settings->logdrain_cloudwatch_region)) { + $errors['logdrain_cloudwatch_region'] = ['The CloudWatch region is required when Amazon CloudWatch Logs is enabled.']; + } + if (blank($settings->logdrain_cloudwatch_group)) { + $errors['logdrain_cloudwatch_group'] = ['The CloudWatch log group is required when Amazon CloudWatch Logs is enabled.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + } + $settings->save(); $server->refresh(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 25aec367bc..7e30184241 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -4262,7 +4262,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $docker_compose['services'][$this->container_name]['labels'] = $labels; } if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { - $docker_compose['services'][$this->container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$this->container_name]['logging'] = generate_log_drain_configuration($this->mainServer); } if ($this->application->settings->is_gpu_enabled) { $docker_compose['services'][$this->container_name]['deploy']['resources']['reservations']['devices'] = [ @@ -6060,9 +6060,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } $unsuccessful = $deployments->first(fn (ApplicationDeploymentQueue $deployment) => $deployment->status !== ApplicationDeploymentStatus::FINISHED->value); + // Additional servers pull the image and do not clone, so only the main deployment has the commit message. + $parentDeployment = ApplicationDeploymentQueue::query()->where('deployment_uuid', $parentDeploymentUuid)->first(); $this->application->environment->project->team?->notify($unsuccessful - ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview) - : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview)); + ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview, $parentDeployment) + : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview, $parentDeployment)); } /** @@ -6071,7 +6073,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function sendDeploymentNotification(string $notificationClass): void { $this->application->environment->project->team?->notify( - new $notificationClass($this->application, $this->deployment_uuid, $this->preview) + new $notificationClass($this->application, $this->deployment_uuid, $this->preview, $this->application_deployment_queue) ); } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 768ebddb22..ec1ac376f2 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -970,7 +970,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function checkLogDrainContainer() { - if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) { + if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) { StartLogDrain::dispatch($this->server); } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index a4fa961345..0ec1ce606f 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue CheckAndStartSentinelJob::dispatch($this->server); } - if ($this->server->isLogDrainEnabled()) { + if ($this->server->isFluentBitLogDrainEnabled()) { $this->checkLogDrainContainer(); } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 0a37c75d16..0913e93507 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -76,6 +76,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $deploymentFailureEmailNotifications = true; + #[Validate(['boolean'])] + public bool $deploymentCommitDetailsEmailNotifications = false; + #[Validate(['boolean'])] public bool $statusChangeEmailNotifications = false; @@ -156,6 +159,7 @@ class Email extends Component $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; + $this->settings->deployment_commit_details_email_notifications = $this->deploymentCommitDetailsEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; $this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; @@ -197,6 +201,7 @@ class Email extends Component $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; + $this->deploymentCommitDetailsEmailNotifications = $this->settings->deployment_commit_details_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; $this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; @@ -231,6 +236,18 @@ class Email extends Component $this->dispatch('success', 'Email notifications settings updated.'); } + public function instantSaveDeploymentCommitDetails() + { + try { + $this->resetErrorBag(); + $this->saveModel(); + } catch (\Throwable $e) { + $this->deploymentCommitDetailsEmailNotifications = $this->settings->deployment_commit_details_email_notifications; + + return handleError($e, $this); + } + } + public function instantSave(?string $type = null) { try { diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 01a89b721a..daa6754480 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -34,14 +34,33 @@ class Terminal extends Component public string $variant = 'default'; + /** + * Whether the selected container can be attached to (its main process keeps stdin open). + */ + public bool $attachAvailable = false; + + /** + * Active terminal mode: 'shell' (docker exec) or 'attach' (docker attach). + */ + public string $terminalMode = 'shell'; + + // Remembered target so the mode switch can reconnect without re-selecting a container. + public bool $currentIsContainer = false; + + public ?string $currentIdentifier = null; + + public ?string $currentServerUuid = null; + private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); + // Non-root SSH users need sudo to reach the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; try { instant_remote_process([ - "docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". - "docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", - ], $server); + "{$sudo}docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". + "{$sudo}docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", + ], $server, no_sudo: true); return true; } catch (Throwable) { @@ -49,8 +68,40 @@ class Terminal extends Component } } + private function containerKeepsStdinOpen(Server $server, string $container): bool + { + $escapedContainer = escapeshellarg($container); + // Non-root SSH users need sudo to reach the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; + try { + $output = instant_remote_process([ + "{$sudo}docker inspect --format '{{.Config.OpenStdin}}' {$escapedContainer} 2>/dev/null", + ], $server, false, no_sudo: true); + + return is_string($output) && str_starts_with(trim($output), 'true'); + } catch (Throwable) { + return false; + } + } + + /** + * Resolve the effective terminal mode. Falls back to shell whenever attach is not available. + */ + public function resolveMode(?string $requestedMode): string + { + if ($requestedMode === 'attach') { + return $this->attachAvailable ? 'attach' : 'shell'; + } + + if ($requestedMode === 'shell') { + return 'shell'; + } + + return $this->attachAvailable ? 'attach' : 'shell'; + } + #[On('send-terminal-command')] - public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService): void + public function sendTerminalCommand($isContainer, $identifier, $serverUuid, ?string $requestedMode = null): void { try { $this->authorize('canAccessTerminal'); @@ -69,6 +120,11 @@ class Terminal extends Component return; } + // Remember the target so the mode switch can reconnect without re-selecting. + $this->currentIsContainer = (bool) $isContainer; + $this->currentIdentifier = $identifier; + $this->currentServerUuid = $serverUuid; + if ($isContainer) { // Validate container identifier format (alphanumeric, dashes, and underscores only) if (! is_string($identifier) || ! ValidationPatterns::isValidContainerName($identifier)) { @@ -77,7 +133,7 @@ class Terminal extends Component return; } - // Verify container exists and belongs to the user's team + // Verify the container is running on the authorized server $status = getContainerStatus($server, $identifier); if ($status !== 'running') { $this->failTerminalSession('The container is not running.'); @@ -85,16 +141,25 @@ class Terminal extends Component return; } - // Check shell availability + // Check shell availability and whether the container can be attached to. $this->hasShell = $this->checkShellAvailability($server, $identifier); - if (! $this->hasShell) { + $this->attachAvailable = $this->containerKeepsStdinOpen($server, $identifier); + + $mode = $this->resolveMode($requestedMode); + $this->terminalMode = $mode; + + // Shell mode needs a shell; console (attach) mode does not. + if ($mode === 'shell' && ! $this->hasShell) { $this->failTerminalSession('No shell is available in this container.'); return; } + } else { + $this->attachAvailable = false; + $mode = $this->terminalMode = 'shell'; } - $token = $terminalSessionService->issue(auth()->user(), $server, $isContainer ? $identifier : null); + $token = app(TerminalSessionService::class)->issue(auth()->user(), $server, $isContainer ? $identifier : null, $mode); $this->dispatch('send-terminal-token', $token); } @@ -120,6 +185,23 @@ class Terminal extends Component $this->dispatch(self::SESSION_FAILED_EVENT, message: $message)->self(); } + /** + * Switch between shell and console (attach) mode and reconnect to the current container. + */ + #[On('set-terminal-mode')] + public function setMode(string $mode): void + { + if (! in_array($mode, ['shell', 'attach'], true)) { + return; + } + + if (! $this->currentIsContainer || $this->currentIdentifier === null || $this->currentServerUuid === null) { + return; + } + + $this->sendTerminalCommand(true, $this->currentIdentifier, $this->currentServerUuid, $mode); + } + #[On('terminalConnected')] public function markTerminalConnected(): void { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index ef9addf231..c48816aa40 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -7,6 +7,7 @@ use App\Actions\Server\StopLogDrain; use App\Models\Server; use App\Rules\ValidFluentBitConfig; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -43,6 +44,18 @@ class LogDrains extends Component #[Validate(['string', 'nullable'])] public ?string $logDrainCustomConfigParser = null; + #[Validate(['boolean'])] + public bool $isLogDrainCloudwatchEnabled = false; + + #[Validate(['string', 'nullable', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'])] + public ?string $logDrainCloudwatchRegion = null; + + #[Validate(['string', 'nullable', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'])] + public ?string $logDrainCloudwatchGroup = null; + + #[Validate(['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'])] + public ?string $logDrainCloudwatchStreamPrefix = null; + public function mount(string $server_uuid) { try { @@ -100,6 +113,21 @@ class LogDrains extends Component } } + private function syncDataCloudwatch(bool $toModel = false): void + { + if ($toModel) { + $this->server->settings->is_logdrain_cloudwatch_enabled = $this->isLogDrainCloudwatchEnabled; + $this->server->settings->logdrain_cloudwatch_region = $this->logDrainCloudwatchRegion; + $this->server->settings->logdrain_cloudwatch_group = $this->logDrainCloudwatchGroup; + $this->server->settings->logdrain_cloudwatch_stream_prefix = $this->logDrainCloudwatchStreamPrefix; + } else { + $this->isLogDrainCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $this->logDrainCloudwatchRegion = $this->server->settings->logdrain_cloudwatch_region; + $this->logDrainCloudwatchGroup = $this->server->settings->logdrain_cloudwatch_group; + $this->logDrainCloudwatchStreamPrefix = $this->server->settings->logdrain_cloudwatch_stream_prefix; + } + } + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { @@ -110,10 +138,16 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); + } + if ($this->enabledLogDrainCount() > 0) { + $this->server->settings->is_logdrain_highlight_enabled = false; } $this->auditLogDrain('updated'); $this->server->settings->save(); @@ -124,16 +158,27 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); } } } public function customValidation() { + if ($this->enabledLogDrainCount() > 1) { + $this->syncData(); + + throw ValidationException::withMessages([ + 'logDrain' => 'Only one log drain can be enabled at a time. Disable the current log drain first.', + ]); + } + if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ @@ -165,6 +210,18 @@ class LogDrains extends Component } catch (\Throwable $e) { $this->isLogDrainCustomEnabled = false; + throw $e; + } + } elseif ($this->isLogDrainCloudwatchEnabled) { + try { + $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]); + } catch (\Throwable $e) { + $this->isLogDrainCloudwatchEnabled = false; + throw $e; } } @@ -193,6 +250,8 @@ class LogDrains extends Component $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; + $previousCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $previousHighlightEnabled = $this->server->settings->is_logdrain_highlight_enabled; try { $this->authorize('update', $this->server); @@ -207,6 +266,7 @@ class LogDrains extends Component $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; $this->isLogDrainAxiomEnabled = $type === 'axiom'; $this->isLogDrainCustomEnabled = $type === 'custom'; + $this->isLogDrainCloudwatchEnabled = $type === 'cloudwatch'; } $this->syncData(true); @@ -225,6 +285,8 @@ class LogDrains extends Component $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; + $this->server->settings->is_logdrain_cloudwatch_enabled = $previousCloudwatchEnabled; + $this->server->settings->is_logdrain_highlight_enabled = $previousHighlightEnabled; $this->server->settings->save(); $this->syncData(); @@ -248,12 +310,23 @@ class LogDrains extends Component return view('livewire.server.log-drains'); } + private function enabledLogDrainCount(): int + { + return count(array_filter([ + $this->isLogDrainNewRelicEnabled, + $this->isLogDrainAxiomEnabled, + $this->isLogDrainCustomEnabled, + $this->isLogDrainCloudwatchEnabled, + ])); + } + private function enabledProperty(string $type): string { return match ($type) { 'newrelic' => 'isLogDrainNewRelicEnabled', 'axiom' => 'isLogDrainAxiomEnabled', 'custom' => 'isLogDrainCustomEnabled', + 'cloudwatch' => 'isLogDrainCloudwatchEnabled', default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } @@ -283,6 +356,11 @@ class LogDrains extends Component 'logDrainCustomConfig' => ['required', new ValidFluentBitConfig], 'logDrainCustomConfigParser' => ['string', 'nullable', new ValidFluentBitConfig(parsersOnly: true)], ]), + 'cloudwatch' => $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 94d0802d96..98e530ce84 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -33,6 +33,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications', 'deployment_failure_email_notifications', + 'deployment_commit_details_email_notifications', 'status_change_email_notifications', 'restart_limit_reached_email_notifications', 'backup_success_email_notifications', @@ -76,6 +77,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications' => 'boolean', 'deployment_failure_email_notifications' => 'boolean', + 'deployment_commit_details_email_notifications' => 'boolean', 'status_change_email_notifications' => 'boolean', 'restart_limit_reached_email_notifications' => 'boolean', 'backup_success_email_notifications' => 'boolean', diff --git a/app/Models/Server.php b/app/Models/Server.php index 00b10235b3..178b180d22 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2057,6 +2057,14 @@ $siteAddress { } public function isLogDrainEnabled() + { + return $this->isFluentBitLogDrainEnabled() || $this->settings->is_logdrain_cloudwatch_enabled; + } + + /** + * Whether the active log drain relies on the coolify-log-drain Fluent Bit container. + */ + public function isFluentBitLogDrainEnabled(): bool { return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_highlight_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 6085320080..25b430b851 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -27,6 +27,7 @@ use OpenApi\Attributes as OA; 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], + 'is_logdrain_cloudwatch_enabled' => ['type' => 'boolean'], 'is_logdrain_custom_enabled' => ['type' => 'boolean'], 'is_logdrain_highlight_enabled' => ['type' => 'boolean'], 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], @@ -48,6 +49,9 @@ use OpenApi\Attributes as OA; 'is_usable' => ['type' => 'boolean'], 'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'], + 'logdrain_cloudwatch_region' => ['type' => 'string'], + 'logdrain_cloudwatch_group' => ['type' => 'string'], + 'logdrain_cloudwatch_stream_prefix' => ['type' => 'string'], 'logdrain_custom_config' => ['type' => 'string'], 'logdrain_custom_config_parser' => ['type' => 'string'], 'logdrain_highlight_project_id' => ['type' => 'string'], @@ -114,6 +118,10 @@ class ServerSetting extends Model 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', 'concurrent_builds', 'dynamic_timeout', 'force_disabled', @@ -200,6 +208,7 @@ class ServerSetting extends Model 'patch_check_result' => 'array', 'patch_checked_at' => 'datetime', 'is_auto_security_updates_enabled' => 'boolean', + 'is_logdrain_cloudwatch_enabled' => 'boolean', ]; /** diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 8fff7f03bb..376f120c88 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -3,15 +3,19 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; +use App\Traits\IncludesDeploymentCommitDetails; use Illuminate\Notifications\Messages\MailMessage; class DeploymentFailed extends CustomEmailNotification { + use IncludesDeploymentCommitDetails; + public Application $application; public ?ApplicationPreview $preview = null; @@ -30,7 +34,7 @@ class DeploymentFailed extends CustomEmailNotification public ?string $fqdn = null; - public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null) + public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null) { $this->onQueue('high'); $this->application = $application; @@ -45,6 +49,7 @@ class DeploymentFailed extends CustomEmailNotification $this->fqdn = str($this->fqdn)->explode(',')->first(); } $this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->resolveDeploymentCommitDetails($application, $deployment, $preview); } public function via(object $notifiable): array @@ -52,7 +57,7 @@ class DeploymentFailed extends CustomEmailNotification return $notifiable->getEnabledChannels('deployment_failure'); } - public function toMail(): MailMessage + public function toMail(?object $notifiable = null): MailMessage { $mail = new MailMessage; $pull_request_id = data_get($this->preview, 'pull_request_id', 0); @@ -64,6 +69,7 @@ class DeploymentFailed extends CustomEmailNotification $mail->subject('Coolify: Deployment failed of pull request #'.$this->preview->pull_request_id.' of '.$this->application_name.'.'); } $mail->view('emails.application-deployment-failed', [ + 'commit' => $this->deploymentCommitDetailsForMail($notifiable), 'name' => $this->application_name, 'fqdn' => $fqdn, 'deployment_url' => $this->deployment_url, diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 415df5831d..07c3000fb5 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -3,15 +3,19 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; +use App\Traits\IncludesDeploymentCommitDetails; use Illuminate\Notifications\Messages\MailMessage; class DeploymentSuccess extends CustomEmailNotification { + use IncludesDeploymentCommitDetails; + public Application $application; public ?ApplicationPreview $preview = null; @@ -30,7 +34,7 @@ class DeploymentSuccess extends CustomEmailNotification public ?string $fqdn; - public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null) + public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null) { $this->onQueue('high'); $this->application = $application; @@ -45,6 +49,7 @@ class DeploymentSuccess extends CustomEmailNotification $this->fqdn = str($this->fqdn)->explode(',')->first(); } $this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->resolveDeploymentCommitDetails($application, $deployment, $preview); } public function via(object $notifiable): array @@ -52,7 +57,7 @@ class DeploymentSuccess extends CustomEmailNotification return $notifiable->getEnabledChannels('deployment_success'); } - public function toMail(): MailMessage + public function toMail(?object $notifiable = null): MailMessage { $mail = new MailMessage; $pull_request_id = data_get($this->preview, 'pull_request_id', 0); @@ -64,6 +69,7 @@ class DeploymentSuccess extends CustomEmailNotification $mail->subject("Coolify: Pull request #{$pull_request_id} of {$this->application_name} deployed successfully"); } $mail->view('emails.application-deployment-success', [ + 'commit' => $this->deploymentCommitDetailsForMail($notifiable), 'name' => $this->application_name, 'fqdn' => $fqdn, 'deployment_url' => $this->deployment_url, diff --git a/app/Services/TerminalSessionService.php b/app/Services/TerminalSessionService.php index eea260b5a5..b2fb4d09a2 100644 --- a/app/Services/TerminalSessionService.php +++ b/app/Services/TerminalSessionService.php @@ -14,7 +14,10 @@ class TerminalSessionService { private const TOKEN_TTL_SECONDS = 60; - public function issue(User $user, Server $server, ?string $container = null): string + /** + * @param 'shell'|'attach' $mode Container terminal mode: docker exec or docker attach. + */ + public function issue(User $user, Server $server, ?string $container = null, string $mode = 'shell'): string { $token = Str::random(64); @@ -23,6 +26,7 @@ class TerminalSessionService 'team_id' => $user->currentTeam()->id, 'server_uuid' => $server->uuid, 'container' => $container, + 'mode' => $mode === 'attach' ? 'attach' : 'shell', ], now()->addSeconds(self::TOKEN_TTL_SECONDS)); return $token; @@ -63,8 +67,7 @@ class TerminalSessionService throw new AccessDeniedHttpException('Terminal container is not authorized.'); } - $dockerCommand = 'docker exec -it '.escapeshellarg($container).' sh -c '.escapeshellarg($command); - $command = $server->isNonRoot() ? "sudo {$dockerCommand}" : $dockerCommand; + $command = $this->containerCommand($server, $container, data_get($payload, 'mode') === 'attach' ? 'attach' : 'shell'); } return SshMultiplexingHelper::generateSshCommand( @@ -74,6 +77,34 @@ class TerminalSessionService ); } + /** + * Build the docker command that runs on the remote server for the given mode. + * + * @param 'shell'|'attach' $mode + */ + public function containerCommand(Server $server, string $container, string $mode): string + { + $escapedContainer = escapeshellarg($container); + // Add sudo for non-root users to access the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; + + if ($mode === 'attach') { + $detachKeys = config('constants.terminal.detach_keys'); + $historyLines = (int) config('constants.terminal.console_history_lines'); + + // Print recent output first so the console is not blank, then attach for live output. + $primeHistory = $historyLines > 0 + ? "{$sudo}docker logs --tail {$historyLines} {$escapedContainer} 2>&1; " + : ''; + + // Attach to the container's main process. --detach-keys lets the user leave with + // Ctrl-P, Ctrl-Q and --sig-proxy=false stops the client forwarding signals to the app. + return "{$primeHistory}exec {$sudo}docker attach --detach-keys=\"{$detachKeys}\" --sig-proxy=false {$escapedContainer}"; + } + + return "{$sudo}docker exec -it {$escapedContainer} sh -c ".escapeshellarg($this->shellCommand()); + } + private function shellCommand(): string { return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. diff --git a/app/Traits/IncludesDeploymentCommitDetails.php b/app/Traits/IncludesDeploymentCommitDetails.php new file mode 100644 index 0000000000..6248481266 --- /dev/null +++ b/app/Traits/IncludesDeploymentCommitDetails.php @@ -0,0 +1,79 @@ +commit); + if (preg_match('/^[0-9a-f]{7,64}$/i', $commit) === 1) { + $this->commit_sha = $commit; + $this->commit_url = $application->gitCommitLink($commit); + } + + $message = trim(mb_scrub((string) $deployment->commitMessage(), 'UTF-8')); + if ($message !== '') { + $this->commit_message = Str::limit($message, 1000); + } + + if (is_null($preview) && filled($application->git_branch)) { + $this->commit_branch = $application->git_branch; + } + } + + /** @return array{sha: string|null, short_sha: string|null, url: string|null, branch: string|null, message: string|null}|null */ + protected function deploymentCommitDetailsForMail(?object $notifiable): ?array + { + $enabled = (bool) data_get($notifiable, 'emailNotificationSettings.deployment_commit_details_email_notifications', false); + + if (! $enabled || (is_null($this->commit_sha) && is_null($this->commit_message))) { + return null; + } + + return [ + 'sha' => $this->commit_sha, + 'short_sha' => $this->commit_sha ? substr($this->commit_sha, 0, 7) : null, + 'url' => $this->commit_url, + 'branch' => $this->commit_branch ? self::escapeMailMarkdown($this->commit_branch) : null, + 'message' => $this->commit_message ? self::formatCommitMessageForMail($this->commit_message) : null, + ]; + } + + /** Escape Markdown in user-controlled text; Blade escapes the HTML. */ + private static function escapeMailMarkdown(string $text): string + { + return preg_replace('/([\\\\`*_{}\[\]()#+\-.!|~=])/u', '\\\\$1', $text); + } + + private static function formatCommitMessageForMail(string $message): string + { + $paragraphs = preg_split('/\R\s*\R/u', str_replace("\r\n", "\n", $message)); + + return collect($paragraphs) + ->map(fn (string $paragraph): string => collect(preg_split('/\R/u', $paragraph)) + ->map(fn (string $line): string => self::escapeMailMarkdown(trim($line))) + ->filter(fn (string $line): bool => $line !== '') + ->implode("\\\n")) + ->filter(fn (string $paragraph): bool => $paragraph !== '') + ->implode("\n\n"); + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index e4548c0aed..7606c09ad0 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1770,6 +1770,27 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) } } + // Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by + // the long-option regex above. Map them to compose so a single-image app can keep STDIN + // open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an + // interactive main process, e.g. game-server consoles. + // Keep quoted option values together so flags inside an entrypoint are not options. + preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens); + foreach ($tokens[0] as $token) { + if ($token === '--interactive') { + $compose_options->put('stdin_open', true); + } elseif ($token === '--tty') { + $compose_options->put('tty', true); + } elseif (preg_match('/^-[dit]+$/', $token)) { + if (str_contains($token, 'i')) { + $compose_options->put('stdin_open', true); + } + if (str_contains($token, 't')) { + $compose_options->put('tty', true); + } + } + } + return $compose_options->toArray(); } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index aa9639f737..1a8f09c529 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1404,7 +1404,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($server->isLogDrainEnabled()) { if ($resource->isLogDrainEnabled()) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); @@ -2781,7 +2781,7 @@ function serviceParser(Service $resource): Collection if ($server->isLogDrainEnabled()) { if ($serviceAppsLogDrainEnabledMap->get($serviceName)) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 89f408fae8..19049f6426 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3608,7 +3608,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($resource->server)); } if ($serviceLabels->count() > 0) { if ($resource->is_container_label_escape_enabled) { @@ -4429,7 +4429,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($server->isLogDrainEnabled()) { if ($resource instanceof Application && $resource->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($server)); } } if ($serviceLabels->count() > 0) { @@ -4489,6 +4489,31 @@ function generate_fluentd_configuration(): array ]; } +function generate_log_drain_configuration(Server $server): array +{ + if ($server->isFluentBitLogDrainEnabled()) { + return generate_fluentd_configuration(); + } + + $settings = $server->settings; + $options = [ + 'awslogs-region' => $settings->logdrain_cloudwatch_region, + 'awslogs-group' => $settings->logdrain_cloudwatch_group, + 'awslogs-create-group' => 'true', + // Like fluentd-async: a CloudWatch outage or missing credentials must not block or fail the container. + 'mode' => 'non-blocking', + // The stream is named after the container; awslogs-stream is never set because it would override this tag. + 'tag' => ($settings->logdrain_cloudwatch_stream_prefix ?? '').'{{.Name}}', + ]; + + return [ + 'driver' => 'awslogs', + 'options' => collect($options) + ->reject(fn ($value) => blank($value)) + ->all(), + ]; +} + function isAssociativeArray($array) { if ($array instanceof Collection) { diff --git a/config/constants.php b/config/constants.php index 7b06283559..728ba9c8ee 100644 --- a/config/constants.php +++ b/config/constants.php @@ -45,6 +45,12 @@ return [ 'host' => env('TERMINAL_HOST'), 'port' => env('TERMINAL_PORT'), 'command_timeout' => 0, + // Docker's default detach escape for `docker attach`. Pressing this + // sequence (Ctrl-P, Ctrl-Q) leaves the container's main process running. + 'detach_keys' => 'ctrl-p,ctrl-q', + // Recent log lines shown when a console (attach) session opens, so the + // screen is not blank until the next line of live output arrives. + 'console_history_lines' => 200, ], 'pusher' => [ diff --git a/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php new file mode 100644 index 0000000000..97fbd6eecc --- /dev/null +++ b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php @@ -0,0 +1,36 @@ +boolean('is_logdrain_cloudwatch_enabled')->default(false); + $table->string('logdrain_cloudwatch_region')->nullable(); + $table->string('logdrain_cloudwatch_group', 512)->nullable(); + $table->string('logdrain_cloudwatch_stream_prefix')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', + ]); + }); + } +}; diff --git a/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php b/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php new file mode 100644 index 0000000000..2f8e613206 --- /dev/null +++ b/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php @@ -0,0 +1,26 @@ +boolean('deployment_commit_details_email_notifications')->default(false); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('email_notification_settings', 'deployment_commit_details_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('deployment_commit_details_email_notifications'); + }); + } + } +}; diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index 394cf5277d..b5675e53af 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -11,8 +11,10 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + detachPtyProcess, getTerminalProcessEnv, getTerminalSessionTimeout, + isAttachCommand, isAuthorizedTargetHost, rejectTerminalSocket, sanitizeSshArgs, @@ -423,10 +425,13 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + // Attach sessions share the container's main process, so they must be detached, not killed. + userSession.isAttach = isAttachCommand(hereDocContent); ws.send('pty-ready'); ptyProcess.onData((data) => { + if (userSession.ptyProcess !== ptyProcess) return; ws.send(data); }); @@ -437,6 +442,14 @@ async function handleCommand(ws, command, userId) { exitCode, signal, }); + + // A previous PTY can exit late (e.g. an attach session detaching during a mode + // switch) after its replacement is already spawned. Only mutate the shared session + // state when this exit belongs to the session's current PTY. + if (userSession.ptyProcess !== ptyProcess) { + return; + } + ws.send('pty-exited'); userSession.isActive = false; @@ -503,6 +516,10 @@ async function killPtyProcess(userId) { const session = userSessions.get(userId); if (!session?.ptyProcess) return false; + if (session.isAttach) { + return detachPtyProcess(session); + } + return new Promise((resolve) => { // Loop to ensure terminal is killed before continuing let killAttempts = 0; diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index a1207b028a..6acb9645a6 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -138,6 +138,12 @@ export function extractTimeout(commandString) { return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; } +// A `docker attach` session shares the container's main process. On disconnect we +// must detach from it instead of killing it, otherwise we stop the running app. +export function isAttachCommand(commandString) { + return /(?:^|\s)docker\s+attach(?:\s|$)/.test(commandString || ''); +} + function normalizeShellArgument(argument) { if (!argument) { return argument; @@ -393,3 +399,53 @@ export function sanitizeSshArgs(sshArgs) { return sanitizedArgs; } + +// Wait for this PTY to exit, not for mutable session state to change. +export function detachPtyProcess(session) { + const ptyProcess = session.ptyProcess; + if (!ptyProcess || !session.isActive) return Promise.resolve(true); + + return new Promise((resolve) => { + let timer; + let attempts = 0; + let settled = false; + const finish = (exited) => { + if (settled) return; + settled = true; + clearTimeout(timer); + exitListener.dispose(); + if (exited && session.ptyProcess === ptyProcess) { + session.isActive = false; + session.ptyProcess = null; + clearTimeout(session.terminalSessionTimer); + session.terminalSessionTimer = null; + } + resolve(exited); + }; + const exitListener = ptyProcess.onExit(() => finish(true)); + const attemptDetach = () => { + attempts++; + try { + ptyProcess.write('\x10\x11'); + } catch (_) { + // An exit event is still required to confirm teardown. + } + if (settled) return; + timer = setTimeout(() => { + if (attempts < 3) { + attemptDetach(); + return; + } + // --sig-proxy=false prevents forwarding signals to the container. + // Refuse a replacement if killing the local SSH client does not exit. + timer = setTimeout(() => finish(false), 1000); + try { + ptyProcess.kill(); + } catch (_) { + // The bounded exit wait fails closed. + } + }, 500); + }; + attemptDetach(); + }); +} diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index fd6297fad4..b00dfa7d9f 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -9,6 +9,8 @@ import { extractTargetHost, getTerminalProcessEnv, getTerminalSessionTimeout, + isAttachCommand, + detachPtyProcess, isAuthorizedTargetHost, normalizeHostForAuthorization, rejectTerminalSocket, @@ -16,6 +18,16 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('isAttachCommand detects a docker attach session', () => { + assert.equal(isAttachCommand('docker attach --detach-keys="ctrl-p,ctrl-q" --sig-proxy=false \'minecraft\''), true); + assert.equal(isAttachCommand('sudo docker attach --sig-proxy=false \'minecraft\''), true); +}); + +test('isAttachCommand does not match a docker exec shell session', () => { + assert.equal(isAttachCommand("docker exec -it 'web-1' sh -c 'exec $SHELL'"), false); + assert.equal(isAttachCommand(''), false); +}); + test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { assert.deepEqual(getTerminalProcessEnv({ PATH: '/usr/local/bin:/usr/bin:/bin', @@ -458,3 +470,63 @@ test('rejectTerminalSocket handles socket errors so unauthenticated clients cann assert.equal(ws.terminated, true); ws.peerClosed(); }); + +function attachSession() { + const listeners = new Set(); + const process = { + writes: [], + kills: 0, + write(data) { this.writes.push(data); }, + kill() { this.kills++; }, + onExit(listener) { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + exit() { for (const listener of [...listeners]) listener({ exitCode: 0 }); }, + }; + return { ptyProcess: process, isActive: true, terminalSessionTimer: null }; +} + +test('attach teardown waits for delayed exit after the kill fallback', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const process = session.ptyProcess; + let resolved = false; + const completion = detachPtyProcess(session).then(result => { resolved = true; return result; }); + for (let i = 0; i < 3; i++) t.mock.timers.tick(500); + await Promise.resolve(); + assert.equal(process.kills, 1); + assert.equal(resolved, false); + assert.deepEqual(process.writes, ['\x10\x11', '\x10\x11', '\x10\x11']); + process.exit(); + assert.equal(await completion, true); + assert.equal(session.isActive, false); + assert.equal(session.ptyProcess, null); +}); + +test('late attach exit cannot clear a replacement session or its timer', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const process = session.ptyProcess; + const completion = detachPtyProcess(session); + const replacement = attachSession().ptyProcess; + session.ptyProcess = replacement; + const timer = setTimeout(() => {}, 10000); + session.terminalSessionTimer = timer; + process.exit(); + assert.equal(await completion, true); + assert.equal(session.isActive, true); + assert.equal(session.ptyProcess, replacement); + assert.equal(session.terminalSessionTimer, timer); + t.mock.timers.tick(3000); + assert.equal(replacement.writes.length, 0); +}); + +test('attach teardown fails closed if the PTY never exits', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const completion = detachPtyProcess(session); + for (let i = 0; i < 5; i++) t.mock.timers.tick(500); + assert.equal(await completion, false); + assert.equal(session.isActive, true); +}); diff --git a/openapi.json b/openapi.json index 8e3bda3333..271bc33fad 100644 --- a/openapi.json +++ b/openapi.json @@ -16973,6 +16973,28 @@ "logdrain_custom_config_parser": { "description": "Only present with read:sensitive.", "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -17046,6 +17068,28 @@ }, "logdrain_custom_config_parser": { "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -25252,6 +25296,9 @@ "is_logdrain_axiom_enabled": { "type": "boolean" }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, "is_logdrain_custom_enabled": { "type": "boolean" }, @@ -25322,6 +25369,15 @@ "logdrain_axiom_dataset_name": { "type": "string" }, + "logdrain_cloudwatch_region": { + "type": "string" + }, + "logdrain_cloudwatch_group": { + "type": "string" + }, + "logdrain_cloudwatch_stream_prefix": { + "type": "string" + }, "logdrain_custom_config": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index 1dbe1c185c..e76b6f1e1a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10804,6 +10804,10 @@ paths: is_logdrain_custom_enabled: { type: boolean } logdrain_custom_config: { description: 'Only present with read:sensitive.', type: string } logdrain_custom_config_parser: { description: 'Only present with read:sensitive.', type: string } + is_logdrain_cloudwatch_enabled: { type: boolean } + logdrain_cloudwatch_region: { type: [string, 'null'] } + logdrain_cloudwatch_group: { type: [string, 'null'] } + logdrain_cloudwatch_stream_prefix: { description: 'Prepended to the container name to form the log stream name.', type: [string, 'null'] } type: object '401': $ref: '#/components/responses/401' @@ -10852,6 +10856,15 @@ paths: type: string logdrain_custom_config_parser: type: string + is_logdrain_cloudwatch_enabled: + type: boolean + logdrain_cloudwatch_region: + type: [string, 'null'] + logdrain_cloudwatch_group: + type: [string, 'null'] + logdrain_cloudwatch_stream_prefix: + description: 'Prepended to the container name to form the log stream name.' + type: [string, 'null'] type: object responses: '200': @@ -16175,6 +16188,8 @@ components: type: boolean is_logdrain_axiom_enabled: type: boolean + is_logdrain_cloudwatch_enabled: + type: boolean is_logdrain_custom_enabled: type: boolean is_logdrain_highlight_enabled: @@ -16223,6 +16238,12 @@ components: type: string logdrain_axiom_dataset_name: type: string + logdrain_cloudwatch_region: + type: string + logdrain_cloudwatch_group: + type: string + logdrain_cloudwatch_stream_prefix: + type: string logdrain_custom_config: type: string logdrain_custom_config_parser: diff --git a/resources/css/app.css b/resources/css/app.css index d9a36f40da..e21e9611ea 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1084,6 +1084,68 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina background: var(--coollabs-base); } +/* Console (attach) command box. Colours come from the active terminal theme's + foreground (--console-fg), so the input is readable on every console theme. */ +.terminal-console-input { + border-color: color-mix(in srgb, var(--console-fg, #fff) 10%, transparent); +} + +.console-command-row { + box-shadow: inset 0 0 0 1px transparent; + transition: + background-color 150ms ease, + box-shadow 150ms ease; +} + +.console-command-row:hover { + background-color: color-mix(in srgb, var(--console-fg, #fff) 3%, transparent); +} + +.console-command-row:focus-within { + background-color: color-mix(in srgb, var(--console-fg, #fff) 6%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--console-fg, #fff) 22%, transparent); +} + +.console-command-field { + color: var(--console-fg, #fff) !important; + caret-color: var(--console-fg, #fff); +} + +.console-command-field::placeholder { + color: color-mix(in srgb, var(--console-fg, #fff) 40%, transparent); +} + +.console-command-row:focus-within .console-command-field::placeholder { + color: color-mix(in srgb, var(--console-fg, #fff) 28%, transparent); +} + +.console-command-prompt { + color: color-mix(in srgb, var(--console-fg, #fff) 40%, transparent); + transition: color 200ms ease; +} + +.console-command-row:focus-within .console-command-prompt { + color: var(--console-fg, #fff); +} + +.console-command-prompt[data-sent] { + color: #fcd452 !important; +} + +.console-command-pending { + color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); +} + +.console-command-send { + color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); + outline-color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); +} + +.console-command-send:hover { + color: var(--console-fg, #fff); + background-color: color-mix(in srgb, var(--console-fg, #fff) 8%, transparent); +} + .terminal-target-list { scrollbar-width: thin; scrollbar-color: var(--terminal-scrollbar, rgb(255 255 255 / 0.3)) transparent; diff --git a/resources/js/terminal.browser.test.js b/resources/js/terminal.browser.test.js new file mode 100644 index 0000000000..29f607e82e --- /dev/null +++ b/resources/js/terminal.browser.test.js @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; + +test('pty-ready keeps the console input focused after the 500 ms retry', async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('http://terminal.test/', route => route.fulfill({ contentType: 'text/html', body: '
Container output
' })); + await page.goto('http://terminal.test/'); + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + // terminal.js imports these helpers; load them as plain declarations. + const connectionSource = readFileSync(new URL('./terminal-connection.js', import.meta.url), 'utf8') + .replace(/^export /gm, ''); + await page.evaluate(([connectionSource, source]) => { + window.Alpine = { data: (_name, factory) => { window.terminalFactory = factory; } }; + window.applicationTerminalThemes = { system: {} }; + window.MAX_TERMINAL_SESSION_SECONDS = 28800; + window.logTerminal = () => {}; + // Register the actual Alpine component, without its Vite imports. + new Function(`${connectionSource}\nfunction initializeTerminalComponent${source}\ninitializeTerminalComponent();`)(); + const component = window.terminalFactory(); + component.$wire = { terminalMode: 'attach', dispatch() {} }; + component.$refs = { consoleInput: document.querySelector('#consoleInput') }; + component.term = { _initialized: true, focus: () => document.querySelector('.xterm-viewport').focus() }; + component.startTerminalSessionCountdown = () => {}; + component.resizeTerminal = () => {}; + component.handleSocketMessage({ data: 'pty-ready' }); + window.terminalComponent = component; + }, [connectionSource, source]); + await page.waitForTimeout(650); + assert.equal(await page.locator('#consoleInput').evaluate((input) => document.activeElement === input), true); + await page.screenshot({ path: '/tmp/pr11538-console-focus.png' }); + await page.evaluate(() => { + window.terminalComponent.$wire.terminalMode = 'shell'; + window.terminalComponent.handleSocketMessage({ data: 'pty-ready' }); + }); + await page.waitForTimeout(650); + assert.equal(await page.locator('.xterm-viewport').evaluate((terminal) => document.activeElement === terminal), true); + } finally { + await browser.close(); + } +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 420e20fb11..93d196286b 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -205,6 +205,15 @@ export function initializeTerminalComponent() { wasConnectedBeforeHidden: false, mobileToolbarCollapsed: false, terminalModifier: null, + // Console (attach) mode — line-oriented command input, like a game-server panel. + consoleCommand: '', + consoleHistory: [], + consoleHistoryIndex: -1, + // Lines from a multi-line paste, waiting for the user to confirm with Enter. + consolePendingLines: [], + consoleSentFlash: false, + consoleSentFlashTimeout: null, + consoleForegroundColor: '#ffffff', keyboardInset: 0, keyboardAnchorTop: 0, keyboardViewportHeight: 0, @@ -312,6 +321,7 @@ export function initializeTerminalComponent() { logTerminal('warn', '[Terminal] Ignoring terminal token after authentication was rejected.'); return; } + this.applyConsoleMode(); this.beginTerminalSessionStart(); this.sendCommandWhenReady({ terminalToken: token }); }); @@ -330,7 +340,7 @@ export function initializeTerminalComponent() { // Wait for terminal to be ready, then focus const focusWhenReady = () => { if (this.terminalActive && this.term) { - this.term.focus(); + this.focusActiveInput(); } else { setTimeout(focusWhenReady, 100); } @@ -515,6 +525,7 @@ export function initializeTerminalComponent() { const cursorBlink = this.term.options.cursorBlink; this.term.options.cursorBlink = false; this.term.options.theme = { ...applicationTerminalThemes[themeName] }; + this.syncConsoleForeground(); this.term.refresh(0, Math.max(0, this.term.rows - 1)); requestAnimationFrame(() => { @@ -524,7 +535,7 @@ export function initializeTerminalComponent() { this.term.options.cursorBlink = cursorBlink; this.term.refresh(0, Math.max(0, this.term.rows - 1)); - this.term.focus(); + this.focusActiveInput(); logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName)); }); } @@ -580,7 +591,7 @@ export function initializeTerminalComponent() { // Force a refresh this.$nextTick(() => { this.resizeTerminal(); - this.term.focus(); + this.focusActiveInput(); }); } }, @@ -607,6 +618,7 @@ export function initializeTerminalComponent() { ? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system : undefined }); + this.syncConsoleForeground(); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.$nextTick(() => { @@ -851,6 +863,169 @@ export function initializeTerminalComponent() { } }, + isConsoleMode() { + return this.$wire?.terminalMode === 'attach'; + }, + + // Focus the console command box in attach mode, otherwise the xterm. + focusActiveInput() { + if (this.isConsoleMode()) { + this.$refs.consoleInput?.focus(); + } else { + this.term?.focus(); + } + }, + + // Keep the xterm read-only while in console mode and load its command history. + applyConsoleMode() { + const consoleMode = this.isConsoleMode(); + if (this.term) { + this.term.options.disableStdin = consoleMode; + this.term.options.cursorBlink = !consoleMode; + // The output cannot take input, so do not draw a cursor in it. + this.term.options.cursorInactiveStyle = consoleMode ? 'none' : 'outline'; + } + if (consoleMode) { + this.loadConsoleHistory(); + this.$nextTick(() => this.$refs.consoleInput?.focus()); + } + // Let the header mode selector reflect the container's current state. + window.dispatchEvent(new CustomEvent('terminal-mode-updated', { + detail: { + attachAvailable: Boolean(this.$wire?.attachAvailable), + hasShell: this.$wire?.hasShell ?? true, + mode: this.$wire?.terminalMode ?? 'shell', + }, + })); + }, + + loadConsoleHistory() { + // Keep commands in this connection only: names can be reused, and + // localStorage is shared by users and teams on the same browser. + this.consoleHistory = []; + this.consoleHistoryIndex = -1; + this.consoleCommand = ''; + this.consolePendingLines = []; + try { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + if (key?.startsWith('coolify-console-history:')) { + localStorage.removeItem(key); + } + } + } catch (_) { + // History does not depend on browser storage being available. + } + }, + + // Text colour for the command box: the active terminal theme's foreground. + syncConsoleForeground() { + this.consoleForegroundColor = this.term?.options.theme?.foreground || '#ffffff'; + }, + + canSubmitConsoleCommand() { + return this.consolePendingLines.length > 0 || this.consoleCommand.trim() !== ''; + }, + + // Send one printable command line to the process stdin, never terminal control bytes. + sendConsoleLine(command) { + if (command.trim() === '' || /[\x00-\x1f\x7f]/.test(command)) { + return false; + } + // Send the whole line plus a newline to the process stdin. + this.sendMessage({ message: `${command}\n` }); + + if (this.consoleHistory[this.consoleHistory.length - 1] !== command) { + this.consoleHistory.push(command); + this.consoleHistory = this.consoleHistory.slice(-50); + } + + return true; + }, + + submitConsoleCommand() { + if (!this.terminalActive || !this.isConsoleMode()) { + return; + } + + const lines = this.consolePendingLines.length > 0 ? this.consolePendingLines : [this.consoleCommand]; + const sent = lines.filter((line) => this.sendConsoleLine(line)).length; + + this.consolePendingLines = []; + this.consoleHistoryIndex = -1; + if (sent > 0) { + this.consoleCommand = ''; + this.flashConsoleSent(); + // Show the response even when the user scrolled up to read older output. + this.term?.scrollToBottom(); + } + // Clicking Send moves focus to the button; return it to the input. + this.$refs.consoleInput?.focus(); + }, + + flashConsoleSent() { + clearTimeout(this.consoleSentFlashTimeout); + this.consoleSentFlash = true; + this.consoleSentFlashTimeout = setTimeout(() => { + this.consoleSentFlash = false; + }, 300); + }, + + // A multi-line paste waits for confirmation, so pasted logs are not sent to the process by mistake. + pasteConsoleText(event) { + const text = event.clipboardData?.getData('text') ?? ''; + if (!/[\r\n]/.test(text)) { + return; + } + event.preventDefault(); + + const lines = text.split(/\r?\n|\r/).filter((line) => line.trim() !== ''); + if (lines.length === 1) { + this.consoleCommand += lines[0]; + return; + } + if (this.consoleCommand.trim() !== '') { + lines[0] = this.consoleCommand + lines[0]; + } + this.consolePendingLines = lines; + this.consoleCommand = ''; + }, + + // Esc cancels a pending paste first, then clears the input. + cancelConsoleInput() { + if (this.consolePendingLines.length > 0) { + this.consolePendingLines = []; + return; + } + this.consoleCommand = ''; + this.consoleHistoryIndex = -1; + }, + + // Ctrl+L clears the visible output only; nothing is sent to the process. + clearConsoleOutput() { + this.term?.clear(); + }, + + // Clicking the read-only output moves focus to the command box, unless the user is selecting text. + focusConsoleFromOutput() { + if (this.isConsoleMode() && !this.term?.hasSelection()) { + this.$refs.consoleInput?.focus(); + } + }, + + recallConsoleCommand(direction) { + if (this.consoleHistory.length === 0) { + return; + } + if (this.consoleHistoryIndex === -1) { + this.consoleHistoryIndex = this.consoleHistory.length; + } + let index = this.consoleHistoryIndex + direction; + index = Math.max(0, Math.min(this.consoleHistory.length, index)); + this.consoleHistoryIndex = index; + this.consoleCommand = index >= this.consoleHistory.length ? '' : this.consoleHistory[index]; + }, + handleSocketMessage(event) { // Handle pong responses if (event.data === 'pong') { @@ -882,7 +1057,9 @@ export function initializeTerminalComponent() { } this.terminalActive = true; this.startTerminalSessionCountdown(); - this.term.focus(); + // In console (attach) mode the user types into the command box, not the + // read-only xterm, so keep focus on that input instead of the terminal. + this.focusActiveInput(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); // Initial resize after terminal is ready @@ -893,13 +1070,13 @@ export function initializeTerminalComponent() { this.resizeTerminal(); }, 200); - // Ensure terminal gets focus after connection with multiple attempts + // Ensure focus after connection with multiple attempts setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 100); - + setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 500); // Notify parent component that terminal is connected @@ -972,6 +1149,11 @@ export function initializeTerminalComponent() { if (!this.term) return; this.term.onData((data) => { + // Console (attach) mode is read-only: commands go through the input box, + // so raw keystrokes (including Ctrl-C) are never sent to the process. + if (this.isConsoleMode()) { + return; + } this.sendMessage({ message: data }); if (data === '\r') { this.commandBuffer = ''; @@ -982,6 +1164,14 @@ export function initializeTerminalComponent() { // Copy and paste functionality this.term.attachCustomKeyEventHandler((arg) => { + // The console output is read-only: move typed characters to the command box. + if (this.isConsoleMode() && arg.type === 'keydown' && arg.key.length === 1 + && !arg.ctrlKey && !arg.metaKey && !arg.altKey) { + // Moving focus during keydown makes the browser type this key into the input. + this.$refs.consoleInput?.focus(); + return false; + } + if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") { return false; } @@ -1012,6 +1202,11 @@ export function initializeTerminalComponent() { return; } + // Console (attach) mode only accepts commands from the input box. + if (this.isConsoleMode()) { + return; + } + this.sendMessage({ message: data }); }, diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js index e0a4fb852d..ed6f9a696d 100644 --- a/resources/js/terminal.test.js +++ b/resources/js/terminal.test.js @@ -1,9 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; import { MAX_TERMINAL_SESSION_SECONDS, formatTerminalSessionRemainingTime, } from './terminal-session-timer.js'; +import * as terminalConnection from './terminal-connection.js'; test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); @@ -13,3 +16,78 @@ test('formatTerminalSessionRemainingTime formats the eight hour terminal limit c assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); }); + +function terminalComponent() { + let factory; + const storage = new Map([['coolify-console-history:server:container', '["secret"]']]); + const localStorage = { + get length() { return storage.size; }, + key: (index) => [...storage.keys()][index], + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }; + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + const componentSource = `function initializeTerminalComponent${source}`; + vm.runInNewContext(`${componentSource}\ninitializeTerminalComponent();`, { + window: { Alpine: { data: (_name, value) => { factory = value; } } }, + localStorage, console, + setTimeout, clearTimeout, + MAX_TERMINAL_SESSION_SECONDS, applicationTerminalThemes: { system: {} }, + ...terminalConnection, + }); + const component = factory(); + component.$wire = { terminalMode: 'attach', currentIdentifier: 'container', currentServerUuid: 'server', dispatch() {} }; + component.$refs = { consoleInput: { focus() {} } }; + component.$nextTick = (callback) => callback(); + component.term = { options: {}, focus() {} }; + component.terminalActive = true; + return { component, storage }; +} + +test('console history does not persist sensitive commands or load previous users history', (t) => { + const { component, storage } = terminalComponent(); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); + component.consoleCommand = 'secret command'; + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + component.submitConsoleCommand(); + assert.deepEqual(messages, ['secret command\n']); + assert.equal(component.consoleHistory.length, 1); + assert.equal(storage.size, 0); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); +}); + +test('console startup and theme focus stay on the command input', (t) => { + const { component } = terminalComponent(); + let consoleFocus = 0; + let terminalFocus = 0; + component.$refs.consoleInput.focus = () => { consoleFocus++; }; + component.term.focus = () => { terminalFocus++; }; + component.focusActiveInput(); + assert.equal(consoleFocus, 1); + assert.equal(terminalFocus, 0); + component.$wire.terminalMode = 'shell'; + component.focusActiveInput(); + assert.equal(terminalFocus, 1); +}); + +test('console input cannot forward control sequences or send into a shell session', () => { + const { component } = terminalComponent(); + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + for (const command of ['\x03', 'list\nstop', '\x10\x11']) { + component.consoleCommand = command; + component.submitConsoleCommand(); + } + component.$wire.terminalMode = 'shell'; + component.consoleCommand = 'stop'; + component.submitConsoleCommand(); + component.$wire.terminalMode = 'attach'; + component.terminalActive = false; + component.submitConsoleCommand(); + assert.equal(messages.length, 0); +}); diff --git a/resources/views/components/emails/deployment-commit.blade.php b/resources/views/components/emails/deployment-commit.blade.php new file mode 100644 index 0000000000..bf5c23fcf1 --- /dev/null +++ b/resources/views/components/emails/deployment-commit.blade.php @@ -0,0 +1,16 @@ +@props(['commit' => null]) +@if ($commit) +@php + $commitLine = null; + if ($commit['short_sha']) { + $shortSha = e($commit['short_sha']); + $commitLine = '**Commit:** '.($commit['url'] ? "[{$shortSha}](".e($commit['url']).')' : $shortSha); + if ($commit['branch']) { + $commitLine .= ' on '.e($commit['branch']); + } + } + $messageBlock = $commit['message'] ? "**Commit message:**\n\n".e($commit['message']) : null; +@endphp +{!! collect([$commitLine, $messageBlock])->filter()->implode("\n\n") !!} + +@endif diff --git a/resources/views/components/terminal/mode-selector.blade.php b/resources/views/components/terminal/mode-selector.blade.php new file mode 100644 index 0000000000..72513c4e07 --- /dev/null +++ b/resources/views/components/terminal/mode-selector.blade.php @@ -0,0 +1,49 @@ +@php + // Shell (docker exec) vs Console (docker attach). Shown only when the container is + // interactive (keeps stdin open). Alpine state lives on the parent terminal section: + // attachAvailable, terminalMode, modeOpen, setTerminalMode(). + $modes = [ + ['key' => 'shell', 'name' => 'Shell', 'description' => 'Interactive shell (docker exec)'], + ['key' => 'attach', 'name' => 'Console', 'description' => 'Attach to the app process (docker attach)'], + ]; + $modeNames = collect($modes)->pluck('name', 'key'); +@endphp + +
+ + + +
diff --git a/resources/views/emails/application-deployment-failed.blade.php b/resources/views/emails/application-deployment-failed.blade.php index a97c650b2a..937c8cdb8e 100644 --- a/resources/views/emails/application-deployment-failed.blade.php +++ b/resources/views/emails/application-deployment-failed.blade.php @@ -6,5 +6,6 @@ Failed to deploy a pull request #{{ $pull_request_id }} of {{ $name }} at [{{ $fqdn }}]({{ $fqdn }}). @endif + [View Deployment Logs]({{ $deployment_url }}) diff --git a/resources/views/emails/application-deployment-success.blade.php b/resources/views/emails/application-deployment-success.blade.php index bc09a24796..2a1b6321d8 100644 --- a/resources/views/emails/application-deployment-success.blade.php +++ b/resources/views/emails/application-deployment-success.blade.php @@ -6,6 +6,7 @@ Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully [{{ $fqdn }}]({{ $fqdn }}). @endif + [View Deployment Logs]({{ $deployment_url }}) diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index fdc2ac672a..81302b1806 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -176,6 +176,12 @@ ['property' => 'traefikOutdatedEmailNotifications', 'label' => 'Traefik proxy outdated', 'enabled' => $traefikOutdatedEmailNotifications], ]" /> +
+ +
diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index 2d974c0bb1..166086743c 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -66,11 +66,16 @@
+
+ + +
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index 8c5bd50767..4332896e0b 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -12,7 +12,8 @@ @endif - @if (!$hasShell) + {{-- Attach (console) mode does not need a shell, so only warn while Shell mode is active. --}} + @if (!$hasShell && $terminalMode === 'shell') @if ($isApplicationConsole)
@endif -
-
+
+ + · Enter to send · Esc to cancel +
+
+ + + +
+
+ +
diff --git a/resources/views/livewire/server/log-drains.blade.php b/resources/views/livewire/server/log-drains.blade.php index ac908a7c9d..3f3d756414 100644 --- a/resources/views/livewire/server/log-drains.blade.php +++ b/resources/views/livewire/server/log-drains.blade.php @@ -34,7 +34,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" /> @@ -53,7 +53,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" /> @@ -62,6 +62,29 @@ :disabled="$server->isLogDrainEnabled()" />
+ +
+ +
+
+ + + +
+
@@ -70,7 +93,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" />
+ + +
diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index d6bb96d15a..d5f4908ed8 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -192,6 +192,31 @@ describe('PATCH /api/v1/notifications/*', function () { ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); + test('updates the deployment commit details email setting', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/notifications/email') + ->assertSuccessful() + ->assertJsonPath('deployment_commit_details_email_notifications', false); + + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'deployment_commit_details_email_notifications' => true, + ]) + ->assertSuccessful() + ->assertJsonPath('deployment_commit_details_email_notifications', true); + + expect(EmailNotificationSettings::query()->where('team_id', $this->team->id)->value('deployment_commit_details_email_notifications'))->toBeTruthy(); + }); + + test('validates the deployment commit details email setting', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'deployment_commit_details_email_notifications' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('deployment_commit_details_email_notifications'); + }); + test('validates the smtp ehlo domain', function () { $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/email', [ diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index 631ae83241..a131bf22ab 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -240,6 +240,89 @@ describe('Log drains API', function () { expect($this->server->settings->fresh()->logdrain_custom_config)->toBe("[OUTPUT]\n Name stdout"); }); + test('PATCH enables the Amazon CloudWatch Logs drain', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => '/coolify/prod', + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_stream_prefix' => 'docker/', + ]) + ->assertOk() + ->assertJsonPath('is_logdrain_cloudwatch_enabled', true) + ->assertJsonPath('logdrain_cloudwatch_group', '/coolify/prod') + ->assertJsonPath('logdrain_cloudwatch_stream_prefix', 'docker/'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1'); + }); + + test('PATCH rejects enabling CloudWatch without a log group', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logdrain_cloudwatch_group'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); + }); + + test('PATCH rejects enabling CloudWatch without a region', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logdrain_cloudwatch_region'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); + }); + + test('PATCH rejects CloudWatch values that could break the compose file', function (string $field, mixed $value) { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + $field => $value, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + })->with([ + 'prefix with interpolation' => ['logdrain_cloudwatch_stream_prefix', 'docker/${HOME}'], + 'prefix with colon' => ['logdrain_cloudwatch_stream_prefix', 'docker:'], + 'prefix with template braces' => ['logdrain_cloudwatch_stream_prefix', '{{.ID}}'], + 'invalid region' => ['logdrain_cloudwatch_region', 'not a region'], + 'group with spaces' => ['logdrain_cloudwatch_group', 'my group'], + ]); + + test('PATCH rejects enabling CloudWatch together with another log drain', function () { + Queue::fake(); + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => true, + 'logdrain_axiom_dataset_name' => 'ds', + 'logdrain_axiom_api_key' => 'key', + ]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_logdrain_cloudwatch_enabled'); + }); + test('other-team log drains endpoints return 404', function () { $this->withHeaders(serverSubsystemsHeaders()) ->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains") diff --git a/tests/Feature/DeploymentEmailCommitDetailsTest.php b/tests/Feature/DeploymentEmailCommitDetailsTest.php new file mode 100644 index 0000000000..bbba70f796 --- /dev/null +++ b/tests/Feature/DeploymentEmailCommitDetailsTest.php @@ -0,0 +1,319 @@ + 0]); + + $this->team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::factory()->create([ + 'server_id' => $server->id, + 'network' => 'commit-details-'.fake()->unique()->word(), + ]); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'git_repository' => 'https://github.com/acme/shop.git', + 'git_branch' => 'main', + 'fqdn' => 'https://shop.example.com', + ]); +}); + +function createDeploymentRow(Application $application, array $attributes = []): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::create([ + 'application_id' => $application->id, + 'deployment_uuid' => 'deploy-'.fake()->unique()->uuid(), + 'commit' => DEPLOYMENT_EMAIL_COMMIT_SHA, + 'commit_message' => "Add checkout page\n\nHandles card and wallet payments.", + 'status' => 'finished', + ...$attributes, + ]); +} + +function enableCommitDetails(Team $team, bool $enabled = true): void +{ + $team->emailNotificationSettings->update(['deployment_commit_details_email_notifications' => $enabled]); + $team->refresh(); +} + +function renderDeploymentMail(string $notificationClass, Application $application, ApplicationDeploymentQueue $deployment, Team $team, ?ApplicationPreview $preview = null): string +{ + return (string) (new $notificationClass($application, $deployment->deployment_uuid, $preview, $deployment))->toMail($team)->render(); +} + +it('defaults the commit details setting to off', function () { + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse(); +}); + +it('includes commit details in deployment emails when enabled', function (string $notificationClass) { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application); + + $html = renderDeploymentMail($notificationClass, $this->application, $deployment, $this->team); + + expect($html) + ->toContain('3f9c2a7') + ->toContain('https://github.com/acme/shop/commit/'.DEPLOYMENT_EMAIL_COMMIT_SHA) + ->toContain('main') + ->toContain('Add checkout page') + ->toContain('Handles card and wallet payments.'); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('leaves deployment emails unchanged when the setting is off', function (string $notificationClass) { + enableCommitDetails($this->team, false); + $deployment = createDeploymentRow($this->application); + + $html = renderDeploymentMail($notificationClass, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('3f9c2a7') + ->not->toContain('Add checkout page') + ->toContain('View Deployment Logs'); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('hides the commit block when the deployment has no commit information', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, ['commit' => 'HEAD', 'commit_message' => null]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('Commit:') + ->not->toContain('Commit message:') + ->toContain('View Deployment Logs'); +}); + +it('shows the commit for preview deployments without the base branch', function () { + enableCommitDetails($this->team); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/acme/shop/pull/42', + 'fqdn' => 'https://pr-42.shop.example.com', + ]); + $deployment = createDeploymentRow($this->application, ['pull_request_id' => 42]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team, $preview); + + expect($html) + ->toContain('Pull request #42') + ->toContain('3f9c2a7') + ->toContain('Add checkout page') + ->not->toContain(' on main'); +}); + +it('escapes html and markdown in commit messages', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, [ + 'commit_message' => "Fix bug\n[Verify your account](https://evil.example.com)\n# Big heading", + ]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('') + ->toContain('<script>') + ->not->toContain('href="https://evil.example.com"') + ->toContain('[Verify your account](https://evil.example.com)') + ->not->toContain('

Big heading

') + ->toContain('# Big heading'); +}); + +it('lets team admins toggle commit details from email notification settings', function () { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->assertSet('deploymentCommitDetailsEmailNotifications', false) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertHasNoErrors(); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +it('does not let team members change the commit details setting', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertSet('deploymentCommitDetailsEmailNotifications', false); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse(); +}); + +it('does not let admins of another team change the commit details setting', function () { + $otherTeam = Team::factory()->create(); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'admin']); + $this->actingAs($outsider); + session(['currentTeam' => $otherTeam]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails'); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse() + ->and($otherTeam->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +function bigMarkdownCommitMessage(): string +{ + return implode("\n", [ + 'feat(checkout): rebuild payment flow', + '', + '# Heading one', + 'Setext heading', + '==============', + '## Heading two', + '- bullet one', + '* bullet two', + '+ bullet three', + '1. first step', + '2. second step', + '> quoted text', + ' indented code line', + '```php', + 'echo "hello";', + '```', + '| Column A | Column B |', + '| -------- | -------- |', + '| cell 1 | cell 2 |', + '**bold** _italic_ ~~strike~~ `inline code`', + '[Reset your password](https://evil.example.com/reset)', + '![tracking pixel](https://evil.example.com/pixel.png)', + '', + '', + 'click me', + '---', + 'Co-authored-by: Jane Doe ', + 'Emoji check 🚀 ✅', + '', + str_repeat('Long body sentence for truncation. ', 60), + 'THIS_TAIL_SHOULD_BE_TRUNCATED', + ]); +} + +it('renders a big markdown commit message as plain safe text', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, ['commit_message' => bigMarkdownCommitMessage()]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->toContain('feat(checkout): rebuild payment flow') + ->toContain('# Heading one') + ->toContain('==============') + ->toContain('- bullet one') + ->toContain('1. first step') + ->toContain('```php') + ->toContain('| Column A | Column B |') + ->toContain('**bold** _italic_ ~~strike~~ `inline code`') + ->toContain('Co-authored-by: Jane Doe <jane@example.com>') + ->toContain('Emoji check 🚀 ✅') + ->not->toContain("\u{FFFD}") + ->toContain('Long body sentence for truncation.') + ->not->toContain('THIS_TAIL_SHOULD_BE_TRUNCATED') + ->not->toContain('href="https://evil.example.com') + ->not->toContain('src="https://evil.example.com') + ->not->toContain('not->toContain('application, $deployment, $this->team); + + expect($html) + ->toContain('Fix caf') + ->toContain('menu (latin-1 byte)') + ->toContain('View Deployment Logs'); +}); + +it('builds commit details without querying the deployment queue', function (string $notificationClass) { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $notification = new $notificationClass($this->application, $deployment->deployment_uuid, null, $deployment); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($queries->filter(fn (string $query): bool => str_contains($query, 'application_deployment_queues')))->toBeEmpty() + ->and($notification->commit_sha)->toBe(DEPLOYMENT_EMAIL_COMMIT_SHA); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('keeps the old constructor working without a deployment record', function () { + enableCommitDetails($this->team); + + $html = (string) (new DeploymentSuccess($this->application, 'legacy-call'))->toMail($this->team)->render(); + + expect($html) + ->toContain('View Deployment Logs') + ->not->toContain('Commit message:'); +}); + +it('passes the in-memory deployment record from the deployment job', function () { + Notification::fake(); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'deployment_success_email_notifications' => true]); + $deployment = createDeploymentRow($this->application); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + foreach (['application' => $this->application, 'deployment_uuid' => $deployment->deployment_uuid, 'preview' => null, 'application_deployment_queue' => $deployment] as $name => $value) { + $reflection->getProperty($name)->setValue($job, $value); + } + + $reflection->getMethod('sendDeploymentNotification')->invoke($job, DeploymentSuccess::class); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === DEPLOYMENT_EMAIL_COMMIT_SHA + && str_starts_with($notification->commit_message, 'Add checkout page')); +}); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index cf2408faf3..341e9de31f 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -252,3 +252,59 @@ test('ConvertGpusCount', function () { ], ]); }); + +test('ConvertInteractiveShortFlag', function () { + // -i keeps STDIN open so `docker attach` can send input (e.g. game server consoles). + expect(convertDockerRunToCompose('-i'))->toBe([ + 'stdin_open' => true, + ]); +}); + +test('ConvertTtyShortFlag', function () { + expect(convertDockerRunToCompose('-t'))->toBe([ + 'tty' => true, + ]); +}); + +test('ConvertInteractiveTtyCombinedShortFlag', function () { + expect(convertDockerRunToCompose('-it'))->toBe([ + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('ConvertTtyInteractiveCombinedShortFlag', function () { + expect(convertDockerRunToCompose('-ti'))->toBe([ + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('ConvertInteractiveTtyLongFlags', function () { + expect(convertDockerRunToCompose('--interactive --tty'))->toBe([ + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('ConvertInteractiveTtyWithOtherOptions', function () { + expect(convertDockerRunToCompose('-it --cap-add SYS_ADMIN'))->toBe([ + 'cap_add' => ['SYS_ADMIN'], + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('DoesNotSetInteractiveTtyForUnrelatedShortFlags', function () { + // -d (detach) and other short flags without i/t must not enable stdin/tty. + expect(convertDockerRunToCompose('-d'))->toBe([]); +}); + +it('does not read interactive flags inside entrypoint arguments', function () { + expect(convertDockerRunToCompose('--entrypoint "python -i -t script.py"')) + ->toBe(['entrypoint' => 'python -i -t script.py']); +}); + +it('does not treat arbitrary short words as interactive flags', function () { + expect(convertDockerRunToCompose('-invalid'))->toBe([]); +}); diff --git a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php new file mode 100644 index 0000000000..26034973b9 --- /dev/null +++ b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php @@ -0,0 +1,244 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('enables the CloudWatch drain and persists its options', function () { + StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', '/coolify/prod') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', 'docker/') + ->call('toggleLogDrain', 'cloudwatch') + ->assertHasNoErrors() + ->assertSet('isLogDrainCloudwatchEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_group)->toBe('/coolify/prod') + ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1') + ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/'); +}); + +it('requires a log group before enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('requires a region before enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->call('toggleLogDrain', 'cloudwatch') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('rejects stream prefixes that could break the compose file', function (string $prefix) { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', $prefix) + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +})->with(['docker/${HOME}', 'docker:', '{{.ID}}', 'with space']); + +it('enables the CloudWatch drain from the status dropdown', function () { + StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', 'docker/') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/'); +}); + +it('disables other drains when CloudWatch is enabled', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update([ + 'is_logdrain_newrelic_enabled' => true, + 'logdrain_newrelic_license_key' => 'abc123', + 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com', + ]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch') + ->assertSet('isLogDrainNewRelicEnabled', false) + ->assertSet('isLogDrainCloudwatchEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_newrelic_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('reverts the CloudWatch flag when starting the drain fails', function () { + StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom')); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('forbids team members from enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('does not deploy the Fluent Bit container for CloudWatch', function () { + StopLogDrain::mock()->shouldReceive('handle')->once()->andReturn('removed'); + $this->server->settings->update([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]); + + $result = StartLogDrain::run($this->server->fresh()); + + expect($result)->toContain('awslogs'); +}); + +it('turns off the legacy Highlight drain when CloudWatch is enabled', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update([ + 'is_logdrain_highlight_enabled' => true, + 'logdrain_highlight_project_id' => 'legacy-project', + ]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_highlight_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('turns off the legacy Highlight drain when switching drains with toggleLogDrain', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update(['is_logdrain_highlight_enabled' => true]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_highlight_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('keeps the legacy Highlight drain when saving without enabling another drain', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update(['is_logdrain_highlight_enabled' => true]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('submit') + ->assertDispatched('success'); + + expect($this->server->settings->fresh()->is_logdrain_highlight_enabled)->toBeTruthy(); +}); + +it('rejects a save that would enable two log drains at once', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + $this->server->settings->update([ + 'is_logdrain_newrelic_enabled' => true, + 'logdrain_newrelic_license_key' => 'abc123', + 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com', + ]); + + // The UI disables the CloudWatch dropdown here; this simulates a crafted request. + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false) + ->assertSet('isLogDrainNewRelicEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeFalse() + ->and($settings->is_logdrain_newrelic_enabled)->toBeTruthy(); +}); + +it('keeps the Fluent Bit drain running if CloudWatch is also enabled, matching the containers logging config', function () { + Process::fake(); + StopLogDrain::mock()->shouldReceive('handle')->andReturn('removed'); + $this->server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]); + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => true, + 'logdrain_axiom_dataset_name' => 'ds', + 'logdrain_axiom_api_key' => 'key', + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]); + $server = $this->server->fresh(); + + expect(generate_log_drain_configuration($server)['driver'])->toBe('fluentd'); + + StartLogDrain::run($server); + + Process::assertRan(fn ($process) => str_contains($process->command, 'log-drains/docker-compose.yml up -d')); +}); diff --git a/tests/Feature/MultiServerApplicationTest.php b/tests/Feature/MultiServerApplicationTest.php index 2f586e07b8..8f47010ac3 100644 --- a/tests/Feature/MultiServerApplicationTest.php +++ b/tests/Feature/MultiServerApplicationTest.php @@ -320,6 +320,25 @@ describe('combined deployment notification', function () { Notification::assertNothingSent(); }); + test('takes commit details for the summary from the main deployment', function () { + $parent = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => new_public_id(), + 'server_id' => $this->mainServer->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', + 'commit_message' => 'Add checkout page', + ]); + Cache::put("multi-server-deployment-queued:{$parent->deployment_uuid}", true); + $child = createMultiServerDeployment($this->application, $this->extraServer, ApplicationDeploymentStatus::FINISHED->value, $parent->deployment_uuid); + $job = makeMultiServerDeploymentJob(['application' => $this->application, 'application_deployment_queue' => $child]); + + callMultiServerDeploymentJob($job, 'sendMultiServerDeploymentNotification', $parent->deployment_uuid); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === $parent->commit + && $notification->commit_message === 'Add checkout page'); + }); + test('sends a failed notification that links to the failed server deployment', function () { $parentUuid = new_public_id(); Cache::put("multi-server-deployment-queued:{$parentUuid}", true); diff --git a/tests/Feature/StandaloneSqliteTest.php b/tests/Feature/StandaloneSqliteTest.php index b63b9917e5..845d88e5da 100644 --- a/tests/Feature/StandaloneSqliteTest.php +++ b/tests/Feature/StandaloneSqliteTest.php @@ -77,6 +77,32 @@ it('generates a compose file that passes the database files to the image', funct ->and($service['volumes'])->toBe(['sqlite-data-'.$this->database->uuid.':/var/lib/sqlite']); }); +it('uses the server log drain driver when log draining is enabled', function () { + $commands = []; + $executor = Mockery::mock(DatabaseStartCommandExecutor::class); + $executor->shouldReceive('execute')->once()->withArgs(function (array $captured) use (&$commands) { + $commands = $captured; + + return true; + })->andReturn(new Activity); + app()->instance(DatabaseStartCommandExecutor::class, $executor); + $this->server->settings->update([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_group' => 'coolify', + ]); + $this->database->update(['is_log_drain_enabled' => true]); + Server::flushIdentityMap(); + + (new StartSqlite)->handle($this->database->fresh(), new Activity); + + preg_match("/^echo '([^']+)' \| base64 -d/", collect($commands)->first(fn (string $command) => str_contains($command, 'docker-compose.yml')), $matches); + $service = Yaml::parse(base64_decode($matches[1]))['services'][$this->database->uuid]; + + expect($service['logging']['driver'])->toBe('awslogs') + ->and($service['logging']['options']['awslogs-group'])->toBe('coolify'); +}); + it('restores a gzipped backup with .restore into the first database file', function () { $command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false); diff --git a/tests/Feature/TerminalCapabilitiesTest.php b/tests/Feature/TerminalCapabilitiesTest.php new file mode 100644 index 0000000000..7bb648d9b1 --- /dev/null +++ b/tests/Feature/TerminalCapabilitiesTest.php @@ -0,0 +1,58 @@ +set('app.key', 'base64:'.base64_encode(random_bytes(32))); + config()->set('constants.ssh.mux_enabled', false); + Storage::fake('ssh-keys'); + $team = Team::factory()->create(); + $key = PrivateKey::create([ + 'name' => 'Terminal test key', + 'private_key' => RSA::createKey(1024)->toString('OpenSSH'), + 'team_id' => $team->id, + ]); + $server = Server::factory()->create([ + 'user' => $user, + 'team_id' => $team->id, + 'private_key_id' => $key->id, + ]); + Process::fake(fn () => Process::result(output: 'true')); + + $terminal = new Terminal; + expect((new ReflectionMethod(Terminal::class, 'containerKeepsStdinOpen'))->invoke($terminal, $server, 'game-1'))->toBeTrue(); + expect((new ReflectionMethod(Terminal::class, 'checkShellAvailability'))->invoke($terminal, $server, 'game-1'))->toBeTrue(); + + Process::assertRan(function ($process) use ($sudo) { + if (! str_contains($process->command, 'docker inspect')) { + return false; + } + expect($process->command) + ->toContain("\n{$sudo}docker inspect --format '{{.Config.OpenStdin}}' 'game-1' 2>/dev/null\n") + ->not->toContain('sudo sudo'); + + return str_contains($process->command, 'docker inspect'); + }); + Process::assertRan(function ($process) use ($sudo) { + if (! str_contains($process->command, 'docker exec')) { + return false; + } + expect($process->command) + ->toContain("{$sudo}docker exec 'game-1' bash -c 'exit 0' 2>/dev/null || {$sudo}docker exec 'game-1' sh -c 'exit 0' 2>/dev/null") + ->not->toContain('sudo sudo'); + + return true; + }); +})->with([ + 'root' => ['root', ''], + 'non-root' => ['ubuntu', 'sudo '], +]); diff --git a/tests/Feature/TerminalConsoleModeTest.php b/tests/Feature/TerminalConsoleModeTest.php new file mode 100644 index 0000000000..85ec73ea77 --- /dev/null +++ b/tests/Feature/TerminalConsoleModeTest.php @@ -0,0 +1,156 @@ +makePartial(); + $server->shouldReceive('isNonRoot')->andReturn($nonRoot); + + return $server; +} + +it('builds an interactive attach command with safe detach keys for console mode', function () { + $command = app(TerminalSessionService::class)->containerCommand(consoleModeServer(false), 'minecraft-abc123', 'attach'); + + expect($command) + // Primes the console with recent logs, then attaches for live output. + ->toContain("docker logs --tail 200 'minecraft-abc123' 2>&1; ") + ->toContain('exec docker attach --detach-keys="ctrl-p,ctrl-q" --sig-proxy=false \'minecraft-abc123\''); +}); + +it('prefixes sudo for non-root servers in console mode', function () { + $command = app(TerminalSessionService::class)->containerCommand(consoleModeServer(true), 'minecraft-abc123', 'attach'); + + expect($command) + ->toStartWith('sudo docker logs --tail 200 ') + ->toContain('exec sudo docker attach --detach-keys="ctrl-p,ctrl-q" --sig-proxy=false '); +}); + +it('keeps the interactive shell command for shell mode', function () { + $command = app(TerminalSessionService::class)->containerCommand(consoleModeServer(false), 'web-1', 'shell'); + + expect($command) + ->toContain("docker exec -it 'web-1' sh -c") + ->not->toContain('docker attach'); +}); + +it('defaults to console mode when the container keeps stdin open', function () { + $terminal = new Terminal; + $terminal->attachAvailable = true; + + expect($terminal->resolveMode(null))->toBe('attach'); +}); + +it('defaults to shell mode when the container has no open stdin', function () { + $terminal = new Terminal; + $terminal->attachAvailable = false; + + expect($terminal->resolveMode(null))->toBe('shell'); +}); + +it('never selects console mode when attach is unavailable even if requested', function () { + $terminal = new Terminal; + $terminal->attachAvailable = false; + + expect($terminal->resolveMode('attach'))->toBe('shell'); +}); + +it('renders a line-oriented command box for console mode instead of a raw terminal', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($view) + ->toContain('terminal-console-input') + ->toContain('submitConsoleCommand()') + ->toContain('x-ref="consoleInput"') + // The mobile control toolbar (which can send Ctrl-C) is hidden in console mode. + ->toContain("terminalActive && \$wire.terminalMode !== 'attach'") + // The earlier guardrail UI is gone in favour of the command box. + ->not->toContain('detachTerminal()') + ->not->toContain('Ctrl-C can stop the app'); +}); + +it('keeps the console terminal read-only and sends whole command lines', function () { + $client = file_get_contents(resource_path('js/terminal.js')); + + expect($client) + ->toContain('isConsoleMode()') + // Keystrokes are not forwarded while attached; commands carry a trailing newline. + ->toContain('message: `${command}\n`') + ->toContain('this.term.options.disableStdin = consoleMode'); +}); + +it('places the shell/console selector beside the theme selector using shared styling', function () { + $selector = file_get_contents(resource_path('views/components/terminal/mode-selector.blade.php')); + + expect($selector) + // Reuses the shared trigger + menu styling from the theme/renderer selectors. + ->toContain('terminal-theme-trigger') + ->toContain('console-theme-selector') + // Only shown when the container is interactive; drives the parent Alpine state. + ->toContain('x-show="attachAvailable"') + ->toContain('setTerminalMode(') + ->toContain("'key' => 'shell'") + ->toContain("'key' => 'attach'"); + + foreach (['project/shared/execute-container-command', 'terminal/index'] as $view) { + $blade = file_get_contents(resource_path("views/livewire/{$view}.blade.php")); + + expect($blade) + // Grouped with the theme selector so extra header options do not break the layout. + ->toContain('
') + ->toContain('') + ->toContain('terminal-mode-updated.window') + ->toContain("Livewire.dispatch('set-terminal-mode', { mode });"); + } +}); + +it('lets the header selector switch modes through a livewire listener', function () { + $component = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php')); + + expect($component)->toContain("#[On('set-terminal-mode')]"); +}); + +it('prefixes sudo for non-root servers when probing container capabilities', function () { + $component = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php')); + + expect($component) + // Both the shell probe and the OpenStdin inspection must reach the Docker socket. + ->toContain('{$sudo}docker exec {$escapedContainer} bash') + ->toContain("{\$sudo}docker inspect --format '{{.Config.OpenStdin}}'"); +}); + +it('hides the shell option and shell-unavailable notice when no shell exists', function () { + $selector = file_get_contents(resource_path('views/components/terminal/mode-selector.blade.php')); + $terminal = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($selector) + // Shell (docker exec) needs a shell in the image, so hide it when there is none. + ->toContain('x-show="hasShell"'); + + expect($terminal) + // Attach mode does not need a shell, so only warn while shell mode is active. + ->toContain("!\$hasShell && \$terminalMode === 'shell'"); + + foreach (['project/shared/execute-container-command', 'terminal/index'] as $view) { + $blade = file_get_contents(resource_path("views/livewire/{$view}.blade.php")); + + expect($blade) + // The parent Alpine state tracks shell availability for the selector. + ->toContain('hasShell = $event.detail.hasShell') + ->toContain("mode === 'shell' && !this.hasShell"); + } +}); + +it('keeps console history connection-local', function () { + $client = file_get_contents(resource_path('js/terminal.js')); + + expect($client) + ->not->toContain('localStorage.setItem(key, JSON.stringify(this.consoleHistory))') + // The mode-updated event carries shell availability to the header selector. + ->toContain('hasShell: this.$wire?.hasShell ?? true'); +}); diff --git a/tests/Feature/TerminalSessionSecurityTest.php b/tests/Feature/TerminalSessionSecurityTest.php index dca3d59cbd..55650df4ca 100644 --- a/tests/Feature/TerminalSessionSecurityTest.php +++ b/tests/Feature/TerminalSessionSecurityTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\TerminalSessionService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Process; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; uses(RefreshDatabase::class); @@ -102,6 +103,29 @@ it('constructs fixed ssh arguments from the authorized server record', function $this->postJson('/terminal/session', ['token' => $token])->assertForbidden(); }); +it('carries the console mode in the token and attaches instead of exec on redeem', function () { + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $service = app(TerminalSessionService::class); + Process::fake([ + '*docker inspect*' => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + '*' => Process::result(), + ]); + + $attachCommand = $service->redeem($this->user, $service->issue($this->user, $server, 'minecraft-abc123', 'attach')); + $shellCommand = $service->redeem($this->user, $service->issue($this->user, $server, 'minecraft-abc123')); + + expect($attachCommand) + ->toContain('docker attach --detach-keys="ctrl-p,ctrl-q" --sig-proxy=false') + ->not->toContain('docker exec') + ->and($shellCommand) + ->toContain('docker exec -it') + ->not->toContain('docker attach'); +}); + it('rejects option-like and traversal container identifiers before command construction', function (string $container) { $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $server = Server::factory()->create([ diff --git a/tests/Fixtures/terminal-websocket-rejection-server.mjs b/tests/Fixtures/terminal-websocket-rejection-server.mjs index f7fd59ab17..ed2de47f3e 100644 --- a/tests/Fixtures/terminal-websocket-rejection-server.mjs +++ b/tests/Fixtures/terminal-websocket-rejection-server.mjs @@ -5,6 +5,7 @@ // Usage: node terminal-websocket-rejection-server.mjs // close-on-upgrade: complete the handshake, then close with 4401 (session rejected) // reject-token: on the first client frame, send the legacy text message and close with 4403 +// ready: on the first client frame, signal that the PTY is ready // // Prints the listening port on stdout. GET /stats returns {"upgrades": n}. import { createHash } from 'node:crypto'; @@ -57,6 +58,11 @@ server.on('upgrade', (req, socket) => { } socket.once('data', () => { + if (mode === 'ready') { + socket.write(frame(0x1, Buffer.from('pty-ready'))); + return; + } + const message = 'Unauthorized: Terminal token was rejected'; socket.write(frame(0x1, Buffer.from(message))); socket.end(closeFrame(4403, message)); diff --git a/tests/Unit/GenerateLogDrainConfigurationTest.php b/tests/Unit/GenerateLogDrainConfigurationTest.php new file mode 100644 index 0000000000..0ac5eb05b2 --- /dev/null +++ b/tests/Unit/GenerateLogDrainConfigurationTest.php @@ -0,0 +1,77 @@ +setRelation('settings', new ServerSetting($attributes)); + + return $server; +} + +it('uses the fluentd driver when CloudWatch is not the active drain', function () { + $server = serverWithLogDrainSettings(['is_logdrain_axiom_enabled' => true]); + + expect(generate_log_drain_configuration($server))->toBe(generate_fluentd_configuration()); +}); + +it('uses the awslogs driver with every configured option', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'eu-west-1', + 'logdrain_cloudwatch_group' => '/coolify/prod', + 'logdrain_cloudwatch_stream_prefix' => 'docker/', + ]); + + expect(generate_log_drain_configuration($server))->toBe([ + 'driver' => 'awslogs', + 'options' => [ + 'awslogs-region' => 'eu-west-1', + 'awslogs-group' => '/coolify/prod', + 'awslogs-create-group' => 'true', + 'mode' => 'non-blocking', + 'tag' => 'docker/{{.Name}}', + ], + ]); +}); + +it('names streams after the container when no prefix is set', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_group' => 'coolify', + ]); + + expect(generate_log_drain_configuration($server)['options']['tag'])->toBe('{{.Name}}'); +}); + +it('does not fall back to a default region', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + ]); + + expect(generate_log_drain_configuration($server)['options'])->not->toHaveKey('awslogs-region'); +}); + +it('never sets awslogs-stream because it would override the container-name tag', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_stream_prefix' => 'apps/', + ]); + + expect(generate_log_drain_configuration($server)['options'])->not->toHaveKey('awslogs-stream'); +}); + +it('reports CloudWatch as an enabled drain that does not use Fluent Bit', function () { + $cloudwatch = serverWithLogDrainSettings(['is_logdrain_cloudwatch_enabled' => true]); + $axiom = serverWithLogDrainSettings(['is_logdrain_axiom_enabled' => true]); + + expect($cloudwatch->isLogDrainEnabled())->toBeTrue() + ->and($cloudwatch->isFluentBitLogDrainEnabled())->toBeFalse() + ->and($axiom->isLogDrainEnabled())->toBeTrue() + ->and($axiom->isFluentBitLogDrainEnabled())->toBeTrue(); +}); diff --git a/tests/v4/Browser/TerminalConsoleFocusTest.php b/tests/v4/Browser/TerminalConsoleFocusTest.php new file mode 100644 index 0000000000..fd751ab130 --- /dev/null +++ b/tests/v4/Browser/TerminalConsoleFocusTest.php @@ -0,0 +1,83 @@ +set('app.maintenance.store', 'array'); + config()->set('constants.ssh.mux_enabled', false); + $this->terminalServer = null; +}); + +afterEach(function () { + $this->terminalServer?->stop(0); +}); + +it('keeps the active input focused through all PTY startup callbacks', function (bool $attach) { + $stack = seedBrowserResourceStack(); + $stack['server']->settings->update(['is_terminal_enabled' => true]); + Cache::flush(); + + Process::fake(function ($process) use ($attach) { + $command = (string) $process->command; + if (str_contains($command, 'docker ps -a')) { + return Process::result(output: json_encode(['Names' => 'app-terminal-focus-1', 'State' => 'running', 'Labels' => ''])); + } + if (str_contains($command, 'Config.OpenStdin')) { + return Process::result(output: $attach ? 'true' : 'false'); + } + if (str_contains($command, 'docker inspect')) { + return Process::result(output: json_encode(['State' => ['Status' => 'running']])); + } + + return Process::result(); + }); + + $this->terminalServer = new SymfonyProcess(['node', base_path('tests/Fixtures/terminal-websocket-rejection-server.mjs'), 'ready']); + $this->terminalServer->start(); + $this->terminalServer->waitUntil(fn (string $type, string $output) => str_contains($output, "\n")); + config()->set('constants.terminal.protocol', 'ws'); + config()->set('constants.terminal.host', '127.0.0.1'); + config()->set('constants.terminal.port', trim($this->terminalServer->getOutput())); + + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate('/terminal') + ->click('[data-terminal-target-picker="page"] button:has-text("app-terminal-focus-1")') + ->assertVisible('.xterm-viewport') + ->wait(0.75); + + $selector = $attach ? '[x-ref="consoleInput"]' : '.xterm-helper-textarea'; + expect($page->script("document.activeElement.matches('$selector')"))->toBeTrue(); + + // Exercise the same ready handler on reconnect. Blur after the 100 ms callback + // so only the 500 ms callback can restore focus, not the initial focus call. + $focusedInputs = $page->script(<<<'JS' + async () => { + const terminal = Alpine.$data(document.getElementById('terminal-container')); + const focusedInput = () => document.activeElement.matches('[x-ref="consoleInput"]') ? 'console' : + document.activeElement.matches('.xterm-helper-textarea') ? 'shell' : 'other'; + terminal.handleSocketMessage({ data: 'pty-ready' }); + const immediate = focusedInput(); + await new Promise(resolve => setTimeout(resolve, 150)); + const after100ms = focusedInput(); + document.activeElement.blur(); + await new Promise(resolve => setTimeout(resolve, 450)); + return [immediate, after100ms, focusedInput()]; + } + JS); + + $expectedInput = $attach ? 'console' : 'shell'; + expect($focusedInputs)->toBe([$expectedInput, $expectedInput, $expectedInput]); + $page->assertNoJavaScriptErrors() + ->screenshot(filename: $attach ? 'terminal-console-startup-focus' : 'terminal-shell-startup-focus'); +})->with(['console' => true, 'shell' => false]);