Merge remote-tracking branch 'origin/release/v4.5' into release/v4.5

This commit is contained in:
Andras Bacsai
2026-10-19 15:03:38 +02:00
64 changed files with 2480 additions and 53 deletions
+1
View File
@@ -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.
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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;
+9 -1
View File
@@ -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 [];
}
+1 -1
View File
@@ -140,7 +140,7 @@ class StartService
return [];
}
if (! $service->destination?->server?->isLogDrainEnabled()) {
if (! $service->destination?->server?->isFluentBitLogDrainEnabled()) {
return [];
}
@@ -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',
@@ -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();
+6 -4
View File
@@ -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)
);
}
+1 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
CheckAndStartSentinelJob::dispatch($this->server);
}
if ($this->server->isLogDrainEnabled()) {
if ($this->server->isFluentBitLogDrainEnabled()) {
$this->checkLogDrainContainer();
}
+17
View File
@@ -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 {
+90 -8
View File
@@ -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
{
+78
View File
@@ -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.'),
};
}
+2
View File
@@ -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',
+8
View File
@@ -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;
}
+9
View File
@@ -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',
];
/**
@@ -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,
@@ -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,
+34 -3
View File
@@ -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 && '.
@@ -0,0 +1,79 @@
<?php
namespace App\Traits;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use Illuminate\Support\Str;
trait IncludesDeploymentCommitDetails
{
public ?string $commit_sha = null;
public ?string $commit_message = null;
public ?string $commit_url = null;
public ?string $commit_branch = null;
/** Store plain strings from the job's in-memory deployment, so no query runs here. */
protected function resolveDeploymentCommitDetails(Application $application, ?ApplicationDeploymentQueue $deployment, ?ApplicationPreview $preview = null): void
{
if (! $deployment) {
return;
}
$commit = trim((string) $deployment->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");
}
}
+21
View File
@@ -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();
}
+2 -2
View File
@@ -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', []));
+27 -2
View File
@@ -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) {
+6
View File
@@ -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' => [
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->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',
]);
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('email_notification_settings', 'deployment_commit_details_email_notifications')) {
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->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');
});
}
}
};
@@ -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;
+56
View File
@@ -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();
});
}
@@ -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);
});
+56
View File
@@ -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"
},
+21
View File
@@ -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:
+62
View File
@@ -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;
+45
View File
@@ -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: '<div class="xterm-viewport" tabindex="0">Container output</div><input id="consoleInput" placeholder="Console command">' }));
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();
}
});
+203 -8
View File
@@ -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 });
},
+78
View File
@@ -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);
});
@@ -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
@@ -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
<div x-cloak x-show="attachAvailable" class="relative shrink-0" @click.outside="modeOpen = false"
@keydown.escape.window="modeOpen = false">
<button type="button"
class="terminal-theme-trigger flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
@click="modeOpen = !modeOpen" aria-label="Choose terminal mode" aria-haspopup="menu" :aria-expanded="modeOpen">
<svg class="size-3.5 text-white/45" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m5 8 3 3-3 3M12 16h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<span x-text="@js($modeNames)[terminalMode]"></span>
<svg class="size-2.5 text-white/35" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<div x-cloak x-show="modeOpen" x-transition.origin.top.right role="menu"
class="console-theme-selector absolute top-11 right-0 z-50 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
@foreach ($modes as $mode)
{{-- Shell (docker exec) needs a shell in the image; hide it when none is available. --}}
<button type="button" role="menuitemradio" :aria-checked="terminalMode === '{{ $mode['key'] }}'"
@if ($mode['key'] === 'shell') x-show="hasShell" @endif
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
@click="setTerminalMode('{{ $mode['key'] }}')">
<span class="min-w-0 flex-1">
<span class="block text-[12px] font-medium">{{ $mode['name'] }}</span>
<span
class="mt-0.5 block text-[10px] text-neutral-400 dark:text-white/35">{{ $mode['description'] }}</span>
</span>
<svg x-show="terminalMode === '{{ $mode['key'] }}'" class="mt-0.5 size-3 shrink-0 text-[#fcd452]"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
</div>
@@ -6,5 +6,6 @@ Failed to deploy a pull request #{{ $pull_request_id }} of {{ $name }} at
[{{ $fqdn }}]({{ $fqdn }}).
@endif
<x-emails.deployment-commit :commit="$commit ?? null" />
[View Deployment Logs]({{ $deployment_url }})
</x-emails.layout>
@@ -6,6 +6,7 @@ Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully
[{{ $fqdn }}]({{ $fqdn }}).
@endif
<x-emails.deployment-commit :commit="$commit ?? null" />
[View Deployment Logs]({{ $deployment_url }})
</x-emails.layout>
@@ -176,6 +176,12 @@
['property' => 'traefikOutdatedEmailNotifications', 'label' => 'Traefik proxy outdated', 'enabled' => $traefikOutdatedEmailNotifications],
]" />
</div>
<div class="mt-4">
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveDeploymentCommitDetails"
id="deploymentCommitDetailsEmailNotifications"
label="Include commit details in deployment emails"
helper="Adds the commit SHA, branch, and commit message to deployment success and failure emails." />
</div>
</x-application.settings-section>
</div>
</div>
@@ -66,11 +66,16 @@
<section class="mt-8 mb-0! h-[calc(100dvh-8rem)] min-h-[32rem] w-full max-w-none xl:mt-0"
x-on:terminal-theme-selected="setTheme($event.detail.theme)"
x-on:terminal-starting.window="syncTheme()"
x-on:terminal-mode-updated.window="attachAvailable = $event.detail.attachAvailable; terminalMode = $event.detail.mode; hasShell = $event.detail.hasShell"
x-data="{
themeKeys: @js($consoleThemeKeys),
themeAccents: @js($consoleThemeAccents),
consoleTheme: 'system',
themeOpen: false,
attachAvailable: false,
hasShell: true,
terminalMode: 'shell',
modeOpen: false,
containerOpen: false,
targetChosen: @js($selected_container !== 'default'),
selectedContainer: @entangle('selected_container').live,
@@ -86,6 +91,15 @@
localStorage.setItem('coolify-console-theme', theme);
window.dispatchEvent(new CustomEvent('terminal-theme-change', { detail: { theme } }));
},
setTerminalMode(mode) {
this.modeOpen = false;
if (this.terminalMode === mode || (mode === 'shell' && !this.hasShell)) {
return;
}
this.terminalMode = mode;
window.dispatchEvent(new CustomEvent('terminal-starting'));
Livewire.dispatch('set-terminal-mode', { mode });
},
syncTheme() {
const savedTheme = localStorage.getItem('coolify-console-theme');
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
@@ -167,8 +181,11 @@
</div>
@endif
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
:theme-accents="$consoleThemeAccents" />
<div class="ml-auto flex items-center gap-2">
<x-terminal.mode-selector />
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
:theme-accents="$consoleThemeAccents" />
</div>
</header>
<div class="terminal-session-panel flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-6">
@@ -12,7 +12,8 @@
<div class="hidden" aria-hidden="true" wire:poll.keep-alive.30s="keepTerminalPageAlive"></div>
@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)
<div class="flex h-full min-h-[32rem] items-center justify-center">
<x-empty size="lg" title="Shell unavailable"
@@ -79,7 +80,7 @@
</div>
@endif
<div id="terminal" wire:ignore data-terminal-style="{{ $isApplicationConsole ? 'application' : 'default' }}"
<div id="terminal" wire:ignore @mouseup="focusConsoleFromOutput()" data-terminal-style="{{ $isApplicationConsole ? 'application' : 'default' }}"
:class="fullscreen
? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'
: @js($isApplicationConsole
@@ -87,7 +88,40 @@
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')">
</div>
<div x-show="terminalActive" x-cloak class="sm:hidden"
{{-- Command box for console (attach) mode — a terminal input line, never raw keystrokes. --}}
<div x-cloak x-show="terminalActive && $wire.terminalMode === 'attach'"
class="terminal-console-input relative z-[2] mt-2 shrink-0 border-t pt-2"
:class="fullscreen ? 'mx-1 mb-1' : ''"
:style="`--console-fg: ${consoleForegroundColor}`">
<div x-show="consolePendingLines.length > 0" x-cloak
class="console-command-pending mb-1 flex items-center gap-2 pl-2 text-xs">
<span x-text="`${consolePendingLines.length} pasted lines ready to send`"></span>
<span class="opacity-60">· Enter to send · Esc to cancel</span>
</div>
<div class="console-command-row flex min-h-9 cursor-text items-center gap-3 rounded-md pr-1 pl-2"
@click="$refs.consoleInput?.focus()">
<span class="console-command-prompt shrink-0 font-mono text-sm leading-none select-none"
:data-sent="consoleSentFlash ? '' : null" aria-hidden="true">&gt;</span>
<input type="text" x-ref="consoleInput" x-model="consoleCommand"
@keydown.enter.prevent="submitConsoleCommand()"
@keydown.escape.prevent.stop="cancelConsoleInput()"
@keydown.ctrl.l.prevent="clearConsoleOutput()"
@keydown.arrow-up.prevent="recallConsoleCommand(-1)"
@keydown.arrow-down.prevent="recallConsoleCommand(1)"
@paste="pasteConsoleText($event)"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="send"
aria-label="Console command" placeholder="Send a command to the process…"
class="console-command-field min-w-0 flex-1 border-0! bg-transparent! px-0! py-1.5! font-mono text-[16px]! shadow-none! ring-0! outline-none! focus:border-0! focus:shadow-none! focus:ring-0! focus:outline-none! sm:text-[13px]!" />
<button type="button" @click.stop="submitConsoleCommand()" :disabled="!canSubmitConsoleCommand()"
title="Send (Enter) · Clear input (Esc) · Clear output (Ctrl+L)"
class="console-command-send inline-flex h-7 shrink-0 items-center gap-2 rounded px-2 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-40">
Send
<span class="font-mono text-sm opacity-60" aria-hidden="true">↵</span>
</button>
</div>
</div>
<div x-show="terminalActive && $wire.terminalMode !== 'attach'" x-cloak class="sm:hidden"
:class="fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')"
:style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : ''"
data-terminal-mobile-toolbar>
@@ -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)" />
<x-forms.input canGate="update" :canResource="$server" type="password" required
id="logDrainNewRelicLicenseKey" label="License key"
:disabled="$server->isLogDrainEnabled()" />
@@ -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)" />
<x-forms.input canGate="update" :canResource="$server" type="password" required
id="logDrainAxiomApiKey" label="API key"
:disabled="$server->isLogDrainEnabled()" />
@@ -62,6 +62,29 @@
:disabled="$server->isLogDrainEnabled()" />
</div>
</x-application.settings-section>
<x-application.settings-section id="server-cloudwatch-drain-section" title="Amazon CloudWatch Logs"
helper="Send container logs directly to CloudWatch Logs using the Docker awslogs logging driver.<br><br>AWS credentials are read by the Docker daemon on this server, not by Coolify. Attach an IAM instance role, or configure <code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> for the Docker service or in <code>/root/.aws/credentials</code>.<br><br>The credentials need <code>logs:CreateLogStream</code> and <code>logs:PutLogEvents</code>, plus <code>logs:CreateLogGroup</code> if the log group does not exist yet.<br><br>Delivery errors do not stop containers; they appear in the Docker daemon logs (<code>journalctl -u docker</code>). Redeploy or restart resources to apply the logging driver.">
<div class="mb-4 max-w-sm">
<x-forms.listbox canGate="update" :canResource="$server" id="isLogDrainCloudwatchEnabled" label="Status"
onChange="instantSave" :options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Enabled'],
]"
:disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" />
</div>
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="update" :canResource="$server" required
id="logDrainCloudwatchGroup" label="Log group" placeholder="/coolify/production"
:disabled="$server->isLogDrainEnabled()" />
<x-forms.input canGate="update" :canResource="$server"
id="logDrainCloudwatchStreamPrefix" label="Log stream prefix" placeholder="docker/"
helper="Optional. Log streams are named after the container, prefixed with this value (e.g. docker/&lt;container name&gt;)."
:disabled="$server->isLogDrainEnabled()" />
<x-forms.input canGate="update" :canResource="$server" required
id="logDrainCloudwatchRegion" label="Region" placeholder="us-east-1"
:disabled="$server->isLogDrainEnabled()" />
</div>
</x-application.settings-section>
<x-application.settings-section id="server-custom-drain-section" title="Custom Fluent Bit"
helper="Provide a custom Fluent Bit output and optional parser configuration.">
<div class="mb-4 max-w-sm">
@@ -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)" />
</div>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.textarea canGate="update" :canResource="$server" rows="8" required
@@ -65,6 +65,7 @@
<section class="{{ $selected_uuid === 'default' ? 'w-full' : 'terminal-page-console min-h-0 w-full flex-1 overflow-hidden' }}"
x-on:terminal-theme-selected="setTheme($event.detail.theme)"
x-on:terminal-starting.window="syncTheme()"
x-on:terminal-mode-updated.window="attachAvailable = $event.detail.attachAvailable; terminalMode = $event.detail.mode; hasShell = $event.detail.hasShell"
x-data="{
targets: @js($terminalOptions),
multipleServers: @js($servers->count() > 1),
@@ -76,6 +77,10 @@
themeAccents: @js($consoleThemeAccents),
consoleTheme: 'system',
themeOpen: false,
attachAvailable: false,
hasShell: true,
terminalMode: 'shell',
modeOpen: false,
get filteredTargetGroups() {
const query = this.targetSearch.trim().toLowerCase();
const targets = query
@@ -98,6 +103,15 @@
localStorage.setItem('coolify-console-theme', theme);
window.dispatchEvent(new CustomEvent('terminal-theme-change', { detail: { theme } }));
},
setTerminalMode(mode) {
this.modeOpen = false;
if (this.terminalMode === mode || (mode === 'shell' && !this.hasShell)) {
return;
}
this.terminalMode = mode;
window.dispatchEvent(new CustomEvent('terminal-starting'));
Livewire.dispatch('set-terminal-mode', { mode });
},
syncTheme() {
const savedTheme = localStorage.getItem('coolify-console-theme');
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
@@ -260,8 +274,11 @@
@endif
</div>
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
<div class="ml-auto flex items-center gap-2">
<x-terminal.mode-selector />
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
:theme-accents="$consoleThemeAccents" />
</div>
</header>
<div class="terminal-session-panel flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-6">
@@ -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', [
@@ -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")
@@ -0,0 +1,319 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Notifications\Email as NotificationEmail;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const DEPLOYMENT_EMAIL_COMMIT_SHA = '3f9c2a7b1d4e5f60718293a4b5c6d7e8f9012345';
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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 <script>alert(1)</script> 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('<script>alert(1)</script>')
->toContain('&lt;script&gt;')
->not->toContain('href="https://evil.example.com"')
->toContain('[Verify your account](https://evil.example.com)')
->not->toContain('<h1>Big heading</h1>')
->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)',
'<https://evil.example.com/autolink>',
'<img src="https://evil.example.com/x.png" onerror="alert(1)">',
'<a href="https://evil.example.com">click me</a>',
'---',
'Co-authored-by: Jane Doe <jane@example.com>',
'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 &lt;jane@example.com&gt;')
->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('<img')
->not->toContain('<a href="https://evil')
->not->toContain('<h1>')
->not->toContain('<h2>')
->not->toContain('<ul>')
->not->toContain('<ol>')
->not->toContain('<blockquote>')
->not->toContain('<pre>')
->not->toContain('<table>')
->not->toContain('<strong>bold</strong>')
->toContain('---<br />');
});
it('keeps the email working when a commit message has invalid utf-8 bytes', function () {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application, ['commit_message' => "Fix caf\xE9 menu (latin-1 byte)"]);
$html = renderDeploymentMail(DeploymentSuccess::class, $this->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'));
});
@@ -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([]);
});
@@ -0,0 +1,244 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Actions\Server\StopLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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'));
});
@@ -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);
+26
View File
@@ -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);
@@ -0,0 +1,58 @@
<?php
use App\Livewire\Project\Shared\Terminal;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use phpseclib3\Crypt\RSA;
uses(RefreshDatabase::class);
it('probes container capabilities with exactly one elevation layer', function (string $user, string $sudo) {
config()->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 '],
]);
+156
View File
@@ -0,0 +1,156 @@
<?php
use App\Livewire\Project\Shared\Terminal;
use App\Models\Server;
use App\Services\TerminalSessionService;
use Mockery\LegacyMockInterface;
function consoleModeServer(bool $nonRoot): Server
{
/** @var Server&LegacyMockInterface $server */
$server = Mockery::mock(Server::class)->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('<div class="ml-auto flex items-center gap-2">')
->toContain('<x-terminal.mode-selector />')
->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');
});
@@ -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([
@@ -5,6 +5,7 @@
// Usage: node terminal-websocket-rejection-server.mjs <mode>
// 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));
@@ -0,0 +1,77 @@
<?php
use App\Models\Server;
use App\Models\ServerSetting;
function serverWithLogDrainSettings(array $attributes): Server
{
$server = new Server;
$server->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();
});
@@ -0,0 +1,83 @@
<?php
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
config()->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]);