mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/release/v4.5' into release/v4.5
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
if ($this->server->isFluentBitLogDrainEnabled()) {
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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', []));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
+36
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+26
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -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">></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/<container name>)."
|
||||
: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('<script>')
|
||||
->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)',
|
||||
'',
|
||||
'<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 <jane@example.com>')
|
||||
->toContain('Emoji check 🚀 ✅')
|
||||
->not->toContain("\u{FFFD}")
|
||||
->toContain('Long body sentence for truncation.')
|
||||
->not->toContain('THIS_TAIL_SHOULD_BE_TRUNCATED')
|
||||
->not->toContain('href="https://evil.example.com')
|
||||
->not->toContain('src="https://evil.example.com')
|
||||
->not->toContain('<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);
|
||||
|
||||
@@ -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 '],
|
||||
]);
|
||||
@@ -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]);
|
||||
Reference in New Issue
Block a user