mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-19 07:35:25 +00:00
Merge remote-tracking branch 'origin/next' into 7489-pr-investigation
This commit is contained in:
@@ -69,7 +69,7 @@ class SshMultiplexingHelper
|
||||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
if ($server->settings->force_disabled) {
|
||||
throw new \RuntimeException('Server is disabled.');
|
||||
@@ -80,7 +80,8 @@ class SshMultiplexingHelper
|
||||
|
||||
self::validateSshKey($server->privateKey);
|
||||
|
||||
$sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh ';
|
||||
$commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
|
||||
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
||||
|
||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||
$sshCommand .= self::multiplexingOptions($server);
|
||||
|
||||
@@ -1106,7 +1106,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
if ($this->application->docker_registry_image_tag) {
|
||||
if ($this->shouldPushDockerRegistryImageTag()) {
|
||||
// Tag image with docker_registry_image_tag
|
||||
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
|
||||
$this->execute_remote_command(
|
||||
@@ -1130,6 +1130,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldPushDockerRegistryImageTag(): bool
|
||||
{
|
||||
if (blank($this->application->docker_registry_image_tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->pull_request_id === 0;
|
||||
}
|
||||
|
||||
private function generate_image_names()
|
||||
{
|
||||
if ($this->application->dockerfile) {
|
||||
@@ -1293,12 +1302,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
|
||||
});
|
||||
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
|
||||
});
|
||||
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||
$sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||
}
|
||||
$ports = $this->application->main_port();
|
||||
$coolify_envs = $this->generate_coolify_env_variables();
|
||||
@@ -1451,6 +1456,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $envs;
|
||||
}
|
||||
|
||||
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
|
||||
{
|
||||
$key = str($environmentVariable->key);
|
||||
|
||||
return $key->startsWith('SERVICE_FQDN_')
|
||||
|| $key->startsWith('SERVICE_URL_')
|
||||
|| $key->startsWith('SERVICE_NAME_');
|
||||
}
|
||||
|
||||
private function save_runtime_environment_variables()
|
||||
{
|
||||
// This method saves the .env file with ALL runtime variables
|
||||
@@ -1666,11 +1680,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||
->get();
|
||||
|
||||
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
|
||||
// For Docker Compose, filter out generated SERVICE_* variables as we generate these
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
|
||||
});
|
||||
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
@@ -1719,11 +1731,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||
->get();
|
||||
|
||||
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
|
||||
// For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
|
||||
});
|
||||
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
@@ -3019,6 +3029,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
->where('is_buildtime', true)
|
||||
->get();
|
||||
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
@@ -3031,6 +3045,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
->where('is_buildtime', true)
|
||||
->get();
|
||||
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
|
||||
@@ -667,12 +667,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
private function upload_to_s3(): void
|
||||
{
|
||||
if (is_null($this->s3)) {
|
||||
$previousS3StorageId = $this->backup->s3_storage_id;
|
||||
|
||||
$this->backup->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
|
||||
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -144,7 +145,7 @@ class BackupEdit extends Component
|
||||
|
||||
try {
|
||||
$server = null;
|
||||
if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
|
||||
if ($this->backup->database instanceof ServiceDatabase) {
|
||||
$server = $this->backup->database->service->destination->server;
|
||||
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
|
||||
$server = $this->backup->database->destination->server;
|
||||
@@ -170,7 +171,7 @@ class BackupEdit extends Component
|
||||
|
||||
$this->backup->delete();
|
||||
|
||||
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$serviceDatabase = $this->backup->database;
|
||||
|
||||
return redirect()->route('project.service.database.backups', [
|
||||
@@ -182,7 +183,7 @@ class BackupEdit extends Component
|
||||
} else {
|
||||
return redirect()->route('project.database.backup.index', $this->parameters);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||
|
||||
return handleError($e, $this);
|
||||
@@ -207,6 +208,13 @@ class BackupEdit extends Component
|
||||
$this->backup->s3_storage_id = null;
|
||||
}
|
||||
|
||||
// S3 backup cannot be enabled without a valid S3 storage owned by the team
|
||||
$availableS3Ids = collect($this->s3s)->pluck('id');
|
||||
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
|
||||
$this->backup->save_s3 = $this->saveS3 = false;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId = null;
|
||||
}
|
||||
|
||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
|
||||
@@ -214,7 +222,7 @@ class BackupEdit extends Component
|
||||
|
||||
$isValid = validate_cron_expression($this->backup->frequency);
|
||||
if (! $isValid) {
|
||||
throw new \Exception('Invalid Cron / Human expression');
|
||||
throw new Exception('Invalid Cron / Human expression');
|
||||
}
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -48,6 +50,20 @@ class CreateScheduledBackup extends Component
|
||||
|
||||
$this->validate();
|
||||
|
||||
if ($this->saveToS3) {
|
||||
$s3StorageExists = ! is_null($this->s3StorageId)
|
||||
&& S3Storage::where('team_id', currentTeam()->id)
|
||||
->where('is_usable', true)
|
||||
->whereKey($this->s3StorageId)
|
||||
->exists();
|
||||
|
||||
if (! $s3StorageExists) {
|
||||
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$isValid = validate_cron_expression($this->frequency);
|
||||
if (! $isValid) {
|
||||
$this->dispatch('error', 'Invalid Cron / Human expression.');
|
||||
@@ -74,7 +90,7 @@ class CreateScheduledBackup extends Component
|
||||
}
|
||||
|
||||
$databaseBackup = ScheduledDatabaseBackup::create($payload);
|
||||
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($this->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
|
||||
} else {
|
||||
$this->dispatch('refreshScheduledBackups');
|
||||
|
||||
@@ -12,6 +12,8 @@ class Terminal extends Component
|
||||
{
|
||||
public bool $hasShell = true;
|
||||
|
||||
public bool $isTerminalConnected = false;
|
||||
|
||||
private function checkShellAvailability(Server $server, string $container): bool
|
||||
{
|
||||
$escapedContainer = escapeshellarg($container);
|
||||
@@ -65,12 +67,20 @@ class Terminal extends Component
|
||||
$dockerCommand = "sudo {$dockerCommand}";
|
||||
}
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
|
||||
$command = SshMultiplexingHelper::generateSshCommand(
|
||||
$server,
|
||||
$dockerCommand,
|
||||
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||
);
|
||||
} else {
|
||||
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
||||
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
||||
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
|
||||
$command = SshMultiplexingHelper::generateSshCommand(
|
||||
$server,
|
||||
$shellCommand,
|
||||
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||
);
|
||||
}
|
||||
// ssh command is sent back to frontend then to websocket
|
||||
// this is done because the websocket connection is not available here
|
||||
@@ -84,6 +94,23 @@ class Terminal extends Component
|
||||
$this->dispatch('send-back-command', $command);
|
||||
}
|
||||
|
||||
#[On('terminalConnected')]
|
||||
public function markTerminalConnected(): void
|
||||
{
|
||||
$this->isTerminalConnected = true;
|
||||
}
|
||||
|
||||
#[On('terminalDisconnected')]
|
||||
public function markTerminalDisconnected(): void
|
||||
{
|
||||
$this->isTerminalConnected = false;
|
||||
}
|
||||
|
||||
public function keepTerminalPageAlive(): void
|
||||
{
|
||||
$this->isTerminalConnected = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.terminal');
|
||||
|
||||
+40
-26
@@ -1278,15 +1278,19 @@ class Application extends BaseModel
|
||||
return application_configuration_dir()."/{$this->uuid}";
|
||||
}
|
||||
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null)
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
|
||||
|
||||
// Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided,
|
||||
// so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone.
|
||||
$sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
|
||||
$resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand;
|
||||
$sshCommand = $resolvedGitSshCommand
|
||||
? (str_starts_with($resolvedGitSshCommand, 'GIT_SSH_COMMAND=')
|
||||
? $resolvedGitSshCommand
|
||||
: 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"')
|
||||
: 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
|
||||
|
||||
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
|
||||
// Invalid refs will cause the git checkout/fetch command to fail on the remote server.
|
||||
@@ -1297,9 +1301,9 @@ class Application extends BaseModel
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
@@ -1310,10 +1314,10 @@ class Application extends BaseModel
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
$git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
}
|
||||
if ($this->settings->is_git_lfs_enabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull";
|
||||
}
|
||||
|
||||
return $git_clone_command;
|
||||
@@ -1554,6 +1558,11 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
$encodedToken = rawurlencode($github_access_token);
|
||||
|
||||
// Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials.
|
||||
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/");
|
||||
$git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
@@ -1566,7 +1575,7 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = $repoUrl;
|
||||
}
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
@@ -1577,7 +1586,7 @@ class Application extends BaseModel
|
||||
if ($pull_request_id !== 0) {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
|
||||
$escapedPrBranch = escapeshellarg($branch);
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
@@ -1602,12 +1611,13 @@ class Application extends BaseModel
|
||||
$private_key = base64_encode($private_key);
|
||||
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
|
||||
$git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
$gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\"";
|
||||
$git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
@@ -1630,7 +1640,7 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
@@ -1673,12 +1683,13 @@ class Application extends BaseModel
|
||||
}
|
||||
$private_key = base64_encode($private_key);
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
|
||||
$git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
$deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\"";
|
||||
$git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
@@ -1701,7 +1712,7 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
@@ -1709,14 +1720,14 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1737,6 +1748,7 @@ class Application extends BaseModel
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
$otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
if ($git_type === 'gitlab') {
|
||||
@@ -1746,7 +1758,7 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
@@ -1754,14 +1766,14 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2010,13 +2022,15 @@ class Application extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildGitCheckoutCommand($target): string
|
||||
protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string
|
||||
{
|
||||
$escapedTarget = escapeshellarg($target);
|
||||
$command = "git checkout {$escapedTarget}";
|
||||
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
|
||||
$command = "{$gitCommand} checkout {$escapedTarget}";
|
||||
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
$command .= ' && git submodule update --init --recursive';
|
||||
$sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
$command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive";
|
||||
}
|
||||
|
||||
return $command;
|
||||
|
||||
@@ -19,6 +19,7 @@ class S3Storage extends BaseModel
|
||||
private const REQUEST_TIMEOUT_SECONDS = 15;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'name',
|
||||
'description',
|
||||
'region',
|
||||
|
||||
@@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
's3_uploaded' => 'boolean',
|
||||
'local_storage_deleted' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -110,6 +111,7 @@ function connectProxyToNetworks(Server $server)
|
||||
if ($server->isSwarm()) {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null",
|
||||
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
|
||||
@@ -119,6 +121,7 @@ function connectProxyToNetworks(Server $server)
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
|
||||
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
|
||||
@@ -135,7 +138,7 @@ function connectProxyToNetworks(Server $server)
|
||||
* This must be called BEFORE docker compose up since the compose file declares networks as external.
|
||||
*
|
||||
* @param Server $server The server to ensure networks on
|
||||
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
|
||||
* @return Collection Commands to create networks if they don't exist
|
||||
*/
|
||||
function ensureProxyNetworksExist(Server $server)
|
||||
{
|
||||
@@ -144,6 +147,7 @@ function ensureProxyNetworksExist(Server $server)
|
||||
if ($server->isSwarm()) {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"echo 'Ensuring network {$safe} exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
|
||||
@@ -152,6 +156,7 @@ function ensureProxyNetworksExist(Server $server)
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"echo 'Ensuring network {$safe} exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
|
||||
@@ -211,7 +216,7 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
|
||||
$custom_commands[] = $command;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If we can't parse the config, return empty array
|
||||
// Silently fail to avoid breaking the proxy regeneration
|
||||
}
|
||||
@@ -432,7 +437,7 @@ function getExactTraefikVersionFromContainer(Server $server): ?string
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
@@ -479,7 +484,7 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
|
||||
@@ -35,6 +35,7 @@ return [
|
||||
'protocol' => env('TERMINAL_PROTOCOL'),
|
||||
'host' => env('TERMINAL_HOST'),
|
||||
'port' => env('TERMINAL_PORT'),
|
||||
'command_timeout' => 0,
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
extractTimeout,
|
||||
getTerminalSessionTimeout,
|
||||
isAuthorizedTargetHost,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
@@ -63,9 +64,11 @@ function createHttpError(response) {
|
||||
}
|
||||
|
||||
const userSessions = new Map();
|
||||
const terminalDebugEnabled = ['1', 'true', 'yes'].includes(
|
||||
String(process.env.TERMINAL_DEBUG || '').toLowerCase()
|
||||
);
|
||||
const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase();
|
||||
const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase();
|
||||
const terminalDebugEnabled =
|
||||
['local', 'development'].includes(envName)
|
||||
|| ['1', 'true', 'yes', 'on'].includes(debugOverride);
|
||||
|
||||
function logTerminal(level, message, context = {}) {
|
||||
if (!terminalDebugEnabled) {
|
||||
@@ -154,7 +157,6 @@ const verifyClient = async (info, callback) => {
|
||||
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
wss.on('connection', async (ws, req) => {
|
||||
ws.isAlive = true;
|
||||
@@ -168,9 +170,9 @@ wss.on('connection', async (ws, req) => {
|
||||
ptyProcess: null,
|
||||
isActive: false,
|
||||
authorizedIPs: [],
|
||||
lastActivityAt: Date.now(),
|
||||
authReady: false,
|
||||
pendingMessages: [],
|
||||
terminalSessionTimer: null,
|
||||
};
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||
const connectionContext = {
|
||||
@@ -260,29 +262,6 @@ const heartbeat = setInterval(() => {
|
||||
} catch (_) {
|
||||
// ignore — close handler will follow
|
||||
}
|
||||
|
||||
const session = ws.userId ? userSessions.get(ws.userId) : null;
|
||||
if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
|
||||
const idleMs = Date.now() - session.lastActivityAt;
|
||||
logTerminal('warn', 'Closing terminal session due to idle timeout.', {
|
||||
userId: ws.userId,
|
||||
idleMs,
|
||||
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
ws.send('idle-timeout');
|
||||
} catch (_) {
|
||||
// ignore — close still attempted below
|
||||
}
|
||||
killPtyProcess(ws.userId);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ws.close(1000, 'Idle timeout');
|
||||
} catch (_) {
|
||||
// ignore — already closed
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
@@ -290,11 +269,9 @@ wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
const messageHandlers = {
|
||||
message: (session, data) => {
|
||||
session.lastActivityAt = Date.now();
|
||||
session.ptyProcess.write(data);
|
||||
},
|
||||
resize: (session, { cols, rows }) => {
|
||||
session.lastActivityAt = Date.now();
|
||||
cols = cols > 0 ? cols : 80;
|
||||
rows = rows > 0 ? rows : 30;
|
||||
session.ptyProcess.resize(cols, rows)
|
||||
@@ -365,8 +342,14 @@ async function handleCommand(ws, command, userId) {
|
||||
}
|
||||
}
|
||||
|
||||
if (userSession.terminalSessionTimer) {
|
||||
clearTimeout(userSession.terminalSessionTimer);
|
||||
userSession.terminalSessionTimer = null;
|
||||
}
|
||||
|
||||
const commandString = command[0].split('\n').join(' ');
|
||||
const timeout = extractTimeout(commandString);
|
||||
const commandTimeout = extractTimeout(commandString);
|
||||
const terminalSessionTimeout = getTerminalSessionTimeout();
|
||||
const sshArgs = extractSshArgs(commandString);
|
||||
const hereDocContent = extractHereDocContent(commandString);
|
||||
|
||||
@@ -375,7 +358,8 @@ async function handleCommand(ws, command, userId) {
|
||||
logTerminal('log', 'Parsed terminal command metadata.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
commandTimeout,
|
||||
terminalSessionTimeout,
|
||||
sshArgs,
|
||||
authorizedIPs: userSession?.authorizedIPs ?? [],
|
||||
});
|
||||
@@ -414,13 +398,13 @@ async function handleCommand(ws, command, userId) {
|
||||
logTerminal('log', 'Spawning PTY process for terminal session.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
commandTimeout,
|
||||
terminalSessionTimeout,
|
||||
});
|
||||
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
|
||||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
userSession.isActive = true;
|
||||
userSession.lastActivityAt = Date.now();
|
||||
|
||||
ws.send('pty-ready');
|
||||
|
||||
@@ -437,13 +421,16 @@ async function handleCommand(ws, command, userId) {
|
||||
});
|
||||
ws.send('pty-exited');
|
||||
userSession.isActive = false;
|
||||
|
||||
if (userSession.terminalSessionTimer) {
|
||||
clearTimeout(userSession.terminalSessionTimer);
|
||||
userSession.terminalSessionTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (timeout) {
|
||||
setTimeout(async () => {
|
||||
await killPtyProcess(userId);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
userSession.terminalSessionTimer = setTimeout(async () => {
|
||||
await killPtyProcess(userId);
|
||||
}, terminalSessionTimeout * 1000);
|
||||
}
|
||||
|
||||
async function handleError(err, userId) {
|
||||
@@ -485,6 +472,11 @@ async function killPtyProcess(userId) {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!session.isActive || !session.ptyProcess) {
|
||||
if (session.terminalSessionTimer) {
|
||||
clearTimeout(session.terminalSessionTimer);
|
||||
session.terminalSessionTimer = null;
|
||||
}
|
||||
|
||||
logTerminal('log', 'PTY process terminated successfully.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
|
||||
|
||||
export function getTerminalSessionTimeout() {
|
||||
return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
export function extractTimeout(commandString) {
|
||||
const timeoutMatch = commandString.match(/timeout (\d+)/);
|
||||
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
getTerminalSessionTimeout,
|
||||
isAuthorizedTargetHost,
|
||||
normalizeHostForAuthorization,
|
||||
} from './terminal-utils.js';
|
||||
@@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
|
||||
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
|
||||
});
|
||||
|
||||
|
||||
test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
|
||||
assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
||||
assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
||||
assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
.terminal-mobile-key {
|
||||
@apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60;
|
||||
export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60;
|
||||
export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60;
|
||||
|
||||
export function formatTerminalSessionRemainingTime(seconds) {
|
||||
const remainingSeconds = Math.max(0, Math.ceil(seconds));
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(remainingSeconds / 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const secondsPart = remainingSeconds % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`;
|
||||
}
|
||||
+148
-18
@@ -1,5 +1,11 @@
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import {
|
||||
MAX_TERMINAL_SESSION_SECONDS,
|
||||
TERMINAL_SESSION_DANGER_SECONDS,
|
||||
TERMINAL_SESSION_WARNING_SECONDS,
|
||||
formatTerminalSessionRemainingTime,
|
||||
} from './terminal-session-timer.js';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
const terminalDebugEnabled = import.meta.env.DEV;
|
||||
@@ -44,7 +50,7 @@ export function initializeTerminalComponent() {
|
||||
pendingCommand: null,
|
||||
// Last successfully sent SSH command — replayed after a transient reconnect
|
||||
// so the PTY respawns automatically. Cleared on intentional terminations
|
||||
// (pty-exited, idle-timeout, unprocessable).
|
||||
// (pty-exited, unprocessable).
|
||||
lastSentCommand: null,
|
||||
// Resize handling
|
||||
resizeObserver: null,
|
||||
@@ -52,6 +58,10 @@ export function initializeTerminalComponent() {
|
||||
// Visibility handling - prevent disconnects when tab loses focus
|
||||
isDocumentVisible: true,
|
||||
wasConnectedBeforeHidden: false,
|
||||
mobileToolbarCollapsed: false,
|
||||
terminalSessionStartedAt: null,
|
||||
terminalSessionRemainingSeconds: null,
|
||||
terminalSessionCountdownInterval: null,
|
||||
|
||||
init() {
|
||||
this.setupTerminal();
|
||||
@@ -135,6 +145,7 @@ export function initializeTerminalComponent() {
|
||||
this.clearAllTimers();
|
||||
this.connectionState = 'disconnected';
|
||||
this.pendingCommand = null;
|
||||
this.resetTerminalSessionCountdown();
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'Client cleanup');
|
||||
}
|
||||
@@ -157,11 +168,68 @@ export function initializeTerminalComponent() {
|
||||
}
|
||||
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
|
||||
.forEach(timer => timer && clearTimeout(timer));
|
||||
if (this.terminalSessionCountdownInterval) {
|
||||
clearInterval(this.terminalSessionCountdownInterval);
|
||||
}
|
||||
this.keepAliveInterval = null;
|
||||
this.reconnectInterval = null;
|
||||
this.connectionTimeoutId = null;
|
||||
this.pingTimeoutId = null;
|
||||
this.resizeTimeout = null;
|
||||
this.terminalSessionCountdownInterval = null;
|
||||
},
|
||||
|
||||
resetTerminalSessionCountdown() {
|
||||
if (this.terminalSessionCountdownInterval) {
|
||||
clearInterval(this.terminalSessionCountdownInterval);
|
||||
}
|
||||
|
||||
this.terminalSessionStartedAt = null;
|
||||
this.terminalSessionRemainingSeconds = null;
|
||||
this.terminalSessionCountdownInterval = null;
|
||||
},
|
||||
|
||||
startTerminalSessionCountdown() {
|
||||
this.resetTerminalSessionCountdown();
|
||||
this.terminalSessionStartedAt = Date.now();
|
||||
this.updateTerminalSessionCountdown();
|
||||
this.terminalSessionCountdownInterval = setInterval(() => {
|
||||
this.updateTerminalSessionCountdown();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
updateTerminalSessionCountdown() {
|
||||
if (!this.terminalSessionStartedAt) {
|
||||
this.terminalSessionRemainingSeconds = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000;
|
||||
this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds);
|
||||
},
|
||||
|
||||
terminalSessionRemainingLabel() {
|
||||
if (this.terminalSessionRemainingSeconds === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`;
|
||||
},
|
||||
|
||||
terminalSessionTimerClass() {
|
||||
if (this.terminalSessionRemainingSeconds === null) {
|
||||
return 'text-neutral-300 bg-black/70 border-white/10';
|
||||
}
|
||||
|
||||
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) {
|
||||
return 'text-red-200 bg-red-950/80 border-red-500/40';
|
||||
}
|
||||
|
||||
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) {
|
||||
return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40';
|
||||
}
|
||||
|
||||
return 'text-neutral-300 bg-black/70 border-white/10';
|
||||
},
|
||||
|
||||
resetTerminal() {
|
||||
@@ -181,6 +249,7 @@ export function initializeTerminalComponent() {
|
||||
this.paused = false;
|
||||
this.commandBuffer = '';
|
||||
this.pendingCommand = null;
|
||||
this.resetTerminalSessionCountdown();
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
@@ -328,6 +397,7 @@ export function initializeTerminalComponent() {
|
||||
|
||||
this.connectionState = 'disconnected';
|
||||
this.clearAllTimers();
|
||||
this.resetTerminalSessionCountdown();
|
||||
|
||||
// Only reset terminal and reconnect if it wasn't a clean close
|
||||
if (event.code !== 1000) {
|
||||
@@ -424,6 +494,7 @@ export function initializeTerminalComponent() {
|
||||
}
|
||||
}
|
||||
this.terminalActive = true;
|
||||
this.startTerminalSessionCountdown();
|
||||
this.term.focus();
|
||||
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
|
||||
|
||||
@@ -450,27 +521,22 @@ export function initializeTerminalComponent() {
|
||||
if (this.term) this.term.reset();
|
||||
this.terminalActive = false;
|
||||
this.lastSentCommand = null;
|
||||
this.resetTerminalSessionCountdown();
|
||||
this.message = '(sorry, something went wrong, please try again)';
|
||||
|
||||
// Notify parent component that terminal connection failed
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (event.data === 'pty-exited') {
|
||||
this.fullscreen = false;
|
||||
this.mobileToolbarCollapsed = false;
|
||||
this.terminalActive = false;
|
||||
this.resetTerminalSessionCountdown();
|
||||
this.term.reset();
|
||||
this.commandBuffer = '';
|
||||
this.lastSentCommand = null;
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (event.data === 'idle-timeout') {
|
||||
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
|
||||
this.terminalActive = false;
|
||||
if (this.term) {
|
||||
this.term.reset();
|
||||
}
|
||||
this.commandBuffer = '';
|
||||
this.lastSentCommand = null;
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (
|
||||
typeof event.data === 'string' &&
|
||||
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
||||
@@ -478,6 +544,7 @@ export function initializeTerminalComponent() {
|
||||
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
|
||||
this.$wire.dispatch('error', event.data);
|
||||
this.terminalActive = false;
|
||||
this.resetTerminalSessionCountdown();
|
||||
} else {
|
||||
try {
|
||||
this.pendingWrites++;
|
||||
@@ -538,6 +605,64 @@ export function initializeTerminalComponent() {
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
sendTerminalInput(data) {
|
||||
if (!this.term || !this.terminalActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.term.focus();
|
||||
this.sendMessage({ message: data });
|
||||
},
|
||||
|
||||
sendTerminalControl(sequence) {
|
||||
const terminalSequences = {
|
||||
arrowUp: '\x1b[A',
|
||||
arrowDown: '\x1b[B',
|
||||
arrowRight: '\x1b[C',
|
||||
arrowLeft: '\x1b[D',
|
||||
tab: '\t',
|
||||
escape: '\x1b',
|
||||
ctrlC: '\x03'
|
||||
};
|
||||
|
||||
if (terminalSequences[sequence]) {
|
||||
this.sendTerminalInput(terminalSequences[sequence]);
|
||||
}
|
||||
},
|
||||
|
||||
async pasteFromClipboard() {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
this.sendTerminalInput(text);
|
||||
}
|
||||
} catch (error) {
|
||||
logTerminal('warn', '[Terminal] Clipboard paste failed:', error);
|
||||
this.$wire.dispatch('error', 'Clipboard paste permission was denied.');
|
||||
}
|
||||
},
|
||||
|
||||
async copyTerminalSelection() {
|
||||
const selection = this.term?.getSelection();
|
||||
if (!selection) {
|
||||
this.$wire.dispatch('error', 'Select terminal text before copying.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
} catch (error) {
|
||||
logTerminal('warn', '[Terminal] Clipboard copy failed:', error);
|
||||
this.$wire.dispatch('error', 'Clipboard copy permission was denied.');
|
||||
}
|
||||
},
|
||||
|
||||
keepAlive() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage({ ping: true });
|
||||
@@ -629,15 +754,20 @@ export function initializeTerminalComponent() {
|
||||
// Force a refresh of the fit addon dimensions
|
||||
this.fitAddon.fit();
|
||||
|
||||
// Get fresh dimensions after fit
|
||||
const wrapperHeight = this.$refs.terminalWrapper.clientHeight;
|
||||
const wrapperWidth = this.$refs.terminalWrapper.clientWidth;
|
||||
// Get fresh dimensions from the terminal element itself. The mobile
|
||||
// toolbar can live beside the terminal in normal flow, so wrapper dimensions
|
||||
// would include controls that should not be counted as terminal rows.
|
||||
const terminalElement = document.getElementById('terminal');
|
||||
const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight;
|
||||
const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth;
|
||||
|
||||
// Account for terminal container padding (px-2 py-1 = 8px left/right, 4px top/bottom)
|
||||
const horizontalPadding = 16; // 8px * 2 (left + right)
|
||||
const verticalPadding = 8; // 4px * 2 (top + bottom)
|
||||
const height = wrapperHeight - verticalPadding;
|
||||
const width = wrapperWidth - horizontalPadding;
|
||||
// Account for terminal container padding. In fullscreen mobile mode,
|
||||
// the fixed toolbar sits over the terminal container, so reserve its height
|
||||
// when calculating rows to keep the prompt above the controls.
|
||||
const horizontalPadding = 16; // px-2 = 8px * 2 (left + right)
|
||||
const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom)
|
||||
const height = terminalHeight - verticalPadding;
|
||||
const width = terminalWidth - horizontalPadding;
|
||||
|
||||
// Check if dimensions are valid
|
||||
if (height <= 0 || width <= 0) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MAX_TERMINAL_SESSION_SECONDS,
|
||||
formatTerminalSessionRemainingTime,
|
||||
} from './terminal-session-timer.js';
|
||||
|
||||
test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => {
|
||||
assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60);
|
||||
assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s');
|
||||
assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s');
|
||||
assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s');
|
||||
assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s');
|
||||
assert.equal(formatTerminalSessionRemainingTime(0), 'expired');
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
<div id="terminal-container" x-data="terminalData()">
|
||||
@if ($isTerminalConnected)
|
||||
<div class="hidden" aria-hidden="true" wire:poll.keep-alive.30s="keepTerminalPageAlive"></div>
|
||||
@endif
|
||||
@if (!$hasShell)
|
||||
<div class="flex pt-4 items-center justify-center w-full py-4 mx-auto">
|
||||
<div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300">
|
||||
@@ -18,10 +21,37 @@
|
||||
</div>
|
||||
@endif
|
||||
<div x-ref="terminalWrapper"
|
||||
:class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
|
||||
:class="fullscreen ? 'fixed inset-0 z-[9999] m-0 h-[100dvh] w-screen max-w-none overflow-hidden rounded-none !bg-black p-0' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
|
||||
<!-- Terminal container -->
|
||||
<div x-show="terminalActive" x-cloak class="mb-2 flex justify-start">
|
||||
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
|
||||
:class="terminalSessionTimerClass()" x-text="terminalSessionRemainingLabel()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal" wire:ignore
|
||||
:class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive">
|
||||
:class="fullscreen ? (mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black') : 'h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black'"
|
||||
x-show="terminalActive">
|
||||
</div>
|
||||
<div x-show="terminalActive" x-cloak
|
||||
:class="fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'"
|
||||
class="sm:hidden" data-terminal-mobile-toolbar>
|
||||
<div class="mx-auto max-w-3xl rounded-lg border border-white/10 bg-black/90 p-1.5 text-white shadow-lg backdrop-blur">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="px-2 text-[11px] font-medium uppercase tracking-wide text-neutral-400">Terminal keys</span>
|
||||
<button type="button" class="rounded px-2 py-1 text-xs text-neutral-300 hover:bg-white/10 hover:text-white"
|
||||
x-on:click="mobileToolbarCollapsed = !mobileToolbarCollapsed; $nextTick(() => resizeTerminal())"
|
||||
x-text="mobileToolbarCollapsed ? 'Show' : 'Hide'"
|
||||
aria-label="Toggle mobile terminal toolbar"></button>
|
||||
</div>
|
||||
<div x-show="!mobileToolbarCollapsed" class="mt-1 grid grid-cols-6 gap-1">
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowUp')" aria-label="Previous command">↑</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowDown')" aria-label="Next command">↓</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowLeft')" aria-label="Move cursor left">←</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowRight')" aria-label="Move cursor right">→</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('tab')">Tab</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('escape')">Esc</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button title="Minimize" x-show="fullscreen" class="fixed bg-black/40 top-4 right-6 text-white"
|
||||
x-on:click="makeFullscreen"><svg class="w-5 h-5 text-gray-500 hover:text-white bg-black/80"
|
||||
|
||||
@@ -205,6 +205,127 @@ it('filters buildpack control vars from preview build-time env files', function
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not let preview docker compose service names override generated build-time service names', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
postgresapp:
|
||||
image: postgres:16-alpine
|
||||
YAML;
|
||||
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'docker_compose' => $compose,
|
||||
'docker_compose_domains' => '[]',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 241,
|
||||
]);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
$envString = $buildtimeEnvs->implode("\n");
|
||||
|
||||
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'");
|
||||
expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""');
|
||||
expect($envString)->not->toContain('SERVICE_URL_APP=');
|
||||
});
|
||||
|
||||
it('does not let production docker compose service names override generated build-time service names', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
postgresapp:
|
||||
image: postgres:16-alpine
|
||||
YAML;
|
||||
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'docker_compose' => $compose,
|
||||
'docker_compose_domains' => '[]',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => 'stale-postgresapp',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
$envString = $buildtimeEnvs->implode("\n");
|
||||
|
||||
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'");
|
||||
expect($envString)->not->toContain('stale-postgresapp');
|
||||
});
|
||||
|
||||
it('filters docker compose generated service variables from build args', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'https://preview.example.com',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 241,
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
|
||||
|
||||
/** @var Collection $envArgs */
|
||||
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
|
||||
|
||||
expect($envArgs->get('APP_ENV'))->toBe('production');
|
||||
expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse();
|
||||
expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from preview runtime env fallback', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createBackupForEditValidationTest(Team $team, array $overrides = []): ScheduledDatabaseBackup
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-backup-edit-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
return ScheduledDatabaseBackup::create(array_merge([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'database_id' => $database->id,
|
||||
'team_id' => $team->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
if (InstanceSettings::find(0) === null) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('cascades to disabling local backup deletion when S3 is force-disabled', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'disable_local_backup' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
expect($backup->disable_local_backup)->toBeFalsy();
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\CreateScheduledBackup;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'pg-scheduled-backup-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
|
||||
{
|
||||
return S3Storage::create([
|
||||
'name' => $name,
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('rejects enabling S3 backup without a selected S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', null)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage not owned by the current team', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $foreignS3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
|
||||
$s3->update(['team_id' => Team::factory()->create()->id]);
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
|
||||
$s3->update(['is_usable' => false]);
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
});
|
||||
@@ -66,6 +66,48 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload_to_s3 exception message reports the previous s3 storage id', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => 12345,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$reflection->getProperty('s3')->setValue($job, null);
|
||||
|
||||
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage ID: 12345');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$reflection->getProperty('s3')->setValue($job, null);
|
||||
|
||||
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage ID: null');
|
||||
});
|
||||
|
||||
test('deleting s3 storage disables s3 on linked backups', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -24,11 +24,12 @@ it('keeps terminal browser logging restricted to Vite development mode', functio
|
||||
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
|
||||
});
|
||||
|
||||
it('keeps realtime terminal server logging restricted to development environments', function () {
|
||||
it('keeps realtime terminal server logging behind the explicit debug flag', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
|
||||
->toContain('const debugOverride = String(process.env.TERMINAL_DEBUG')
|
||||
->toContain("['1', 'true', 'yes', 'on'].includes(debugOverride)")
|
||||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
@@ -58,22 +59,45 @@ it('uses a fast probe timeout when the tab regains visibility', function () {
|
||||
->toContain("'Visibility-resume timeout'");
|
||||
});
|
||||
|
||||
it('closes idle terminal sessions after 30 minutes on the server', function () {
|
||||
it('does not hard close terminal sessions after 30 minutes on the server', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->toContain('lastActivityAt')
|
||||
->toContain("ws.send('idle-timeout');")
|
||||
->toContain("ws.close(1000, 'Idle timeout');");
|
||||
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->not->toContain("ws.send('idle-timeout');")
|
||||
->not->toContain("ws.close(1000, 'Idle timeout');");
|
||||
});
|
||||
|
||||
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
|
||||
it('does not close the client terminal from an idle-timeout sentinel', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("event.data === 'idle-timeout'")
|
||||
->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
->not->toContain("event.data === 'idle-timeout'")
|
||||
->not->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
});
|
||||
|
||||
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
|
||||
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
|
||||
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toContain('public bool $isTerminalConnected = false;')
|
||||
->toContain("#[On('terminalConnected')]")
|
||||
->toContain('public function markTerminalConnected(): void')
|
||||
->toContain('public function keepTerminalPageAlive(): void')
|
||||
->and($terminalView)
|
||||
->toContain('@if ($isTerminalConnected)')
|
||||
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
|
||||
});
|
||||
|
||||
it('exits fullscreen when the terminal process exits', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("event.data === 'pty-exited'")
|
||||
->toContain('this.fullscreen = false;
|
||||
this.mobileToolbarCollapsed = false;
|
||||
this.terminalActive = false;');
|
||||
});
|
||||
|
||||
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
||||
@@ -104,3 +128,91 @@ it('preserves terminal scrollback across transient reconnects', function () {
|
||||
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
|
||||
->not->toContain("this.term.reset();\n this.term.clear();");
|
||||
});
|
||||
|
||||
it('renders a compact mobile terminal toolbar with shell control keys', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('Terminal keys')
|
||||
->toContain('sm:hidden')
|
||||
->toContain("sendTerminalControl('arrowUp')")
|
||||
->toContain("sendTerminalControl('arrowDown')")
|
||||
->toContain("sendTerminalControl('arrowLeft')")
|
||||
->toContain("sendTerminalControl('arrowRight')")
|
||||
->toContain("sendTerminalControl('tab')")
|
||||
->toContain("sendTerminalControl('escape')")
|
||||
->not->toContain("sendTerminalControl('ctrlC')")
|
||||
->not->toContain('pasteFromClipboard()')
|
||||
->not->toContain('copyTerminalSelection()')
|
||||
->toContain('mobileToolbarCollapsed')
|
||||
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'")
|
||||
->toContain('data-terminal-mobile-toolbar')
|
||||
->and($appCss)
|
||||
->toContain('.terminal-mobile-key');
|
||||
});
|
||||
|
||||
it('sends terminal mobile toolbar controls through the websocket', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('sendTerminalInput(data)')
|
||||
->toContain('sendTerminalControl(sequence)')
|
||||
->toContain("arrowUp: '\\x1b[A'")
|
||||
->toContain("arrowDown: '\\x1b[B'")
|
||||
->toContain("arrowRight: '\\x1b[C'")
|
||||
->toContain("arrowLeft: '\\x1b[D'")
|
||||
->toContain("tab: '\\t'")
|
||||
->toContain("escape: '\\x1b'")
|
||||
->toContain("ctrlC: '\\x03'")
|
||||
->toContain('navigator.clipboard.readText()')
|
||||
->toContain('navigator.clipboard.writeText(selection)');
|
||||
});
|
||||
|
||||
it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("document.getElementById('terminal')")
|
||||
->toContain('terminalHeight')
|
||||
->toContain('terminalWidth')
|
||||
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
|
||||
});
|
||||
|
||||
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain('updateFullscreenLayout()')
|
||||
->not->toContain('terminalFullscreenHeight')
|
||||
->not->toContain('window.visualViewport?.height')
|
||||
->and($terminalView)
|
||||
->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'")
|
||||
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'");
|
||||
});
|
||||
|
||||
it('resizes after toggling the mobile terminal toolbar', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('$nextTick(() => resizeTerminal())');
|
||||
});
|
||||
|
||||
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('fixed inset-0')
|
||||
->toContain('h-[100dvh]')
|
||||
->toContain('w-screen')
|
||||
->toContain('max-w-none')
|
||||
->toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
it('constrains normal terminal height after leaving fullscreen', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden');
|
||||
});
|
||||
|
||||
@@ -73,6 +73,19 @@ it('adds native openssh multiplexing options to ssh commands', function () {
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('can generate terminal ssh commands without a hard command timeout', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
|
||||
|
||||
expect($command)
|
||||
->toStartWith('ssh ')
|
||||
->not->toStartWith('timeout ')
|
||||
->not->toContain('timeout 3600 ssh');
|
||||
});
|
||||
|
||||
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
||||
@@ -74,3 +74,60 @@ it('falls back to latest when neither preview nor application tags are set', fun
|
||||
|
||||
expect($method->invoke($job))->toBe('latest');
|
||||
});
|
||||
|
||||
function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, $pullRequestId);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => $dockerRegistryImageTag,
|
||||
]));
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
it('pushes the configured docker registry image tag for production deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = makeDockerRegistryTagPushJob(
|
||||
pullRequestId: 0,
|
||||
dockerRegistryImageTag: 'latest',
|
||||
);
|
||||
|
||||
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBeTrue();
|
||||
});
|
||||
|
||||
it('skips the configured docker registry image tag for preview deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = makeDockerRegistryTagPushJob(
|
||||
pullRequestId: 42,
|
||||
dockerRegistryImageTag: 'latest',
|
||||
);
|
||||
|
||||
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBeFalse();
|
||||
});
|
||||
|
||||
it('skips pushing a configured docker registry image tag when no tag is set', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = makeDockerRegistryTagPushJob(
|
||||
pullRequestId: 0,
|
||||
dockerRegistryImageTag: null,
|
||||
);
|
||||
|
||||
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models {
|
||||
function generateGithubInstallationToken(GithubApp $source): string
|
||||
{
|
||||
return 'review token/with+symbols';
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GithubApp;
|
||||
|
||||
test('private github app submodule credentials use per command git config', function () {
|
||||
$application = new Application;
|
||||
$application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_repository' => 'coollabsio/private-app',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
$source = new GithubApp;
|
||||
$source->forceFill([
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
$application->setRelation('source', $source);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-deployment',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
expect($result['commands'])
|
||||
->not->toContain('git config --global')
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
|
||||
describe('Git submodule credential propagation', function () {
|
||||
beforeEach(function () {
|
||||
$this->application = new Application;
|
||||
$this->application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$this->application->setRelation('settings', $settings);
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for submodule update', function () {
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
|
||||
->toContain('git submodule sync');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () {
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () {
|
||||
$this->application->git_commit_sha = 'abc123def456';
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () {
|
||||
$this->application->git_commit_sha = 'abc123def456';
|
||||
$this->application->settings->is_git_shallow_clone_enabled = true;
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () {
|
||||
$this->application->settings->is_git_lfs_enabled = true;
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: false,
|
||||
gitSshCommand: $sshCommand
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull');
|
||||
});
|
||||
|
||||
test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () {
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
|
||||
|
||||
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
|
||||
$result = $method->invoke($this->application, 'main', $sshCommand);
|
||||
|
||||
expect($result)
|
||||
->toContain("git checkout 'main'")
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () {
|
||||
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
|
||||
$result = $method->invoke($this->application, 'main');
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () {
|
||||
$this->application->settings->is_git_submodules_enabled = false;
|
||||
|
||||
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
|
||||
$result = $method->invoke($this->application, 'main');
|
||||
|
||||
expect($result)
|
||||
->toContain("git checkout 'main'")
|
||||
->not->toContain('submodule');
|
||||
});
|
||||
|
||||
test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () {
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->git_commit_sha = 'HEAD';
|
||||
$application->setRelation('settings', $settings);
|
||||
$application->source = $gitlabSource;
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'git@gitlab.com:user/repo.git',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-uuid',
|
||||
pull_request_id: 123,
|
||||
git_type: 'gitlab',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
|
||||
->toContain("git checkout 'pr-123-coolify'")
|
||||
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
|
||||
->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use Illuminate\Log\LogManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\SchemalessAttributes\SchemalessAttributes;
|
||||
@@ -83,7 +84,7 @@ YAML;
|
||||
});
|
||||
|
||||
it('logs warning when regenerating defaults', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::swap(new LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
// No DB config, no disk config — will try to regenerate
|
||||
@@ -94,7 +95,7 @@ it('logs warning when regenerating defaults', function () {
|
||||
// the force regenerate path instead
|
||||
try {
|
||||
GetProxyConfiguration::run($server, forceRegenerate: true);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// generateDefaultProxyConfiguration may fail without full server setup
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ it('does not read from disk when DB config exists', function () {
|
||||
});
|
||||
|
||||
it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::swap(new LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
|
||||
@@ -126,7 +127,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
// Both will fail in test env, but the warning log proves mismatch was detected.
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
});
|
||||
|
||||
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::swap(new LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
|
||||
@@ -144,7 +145,7 @@ it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
|
||||
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
@@ -163,7 +164,7 @@ it('accepts stored Caddy config when proxy type is CADDY', function () {
|
||||
});
|
||||
|
||||
it('accepts stored config when YAML parsing fails', function () {
|
||||
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
|
||||
$invalidYaml = 'this: is: not: [valid yaml: {{{}}}';
|
||||
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
|
||||
|
||||
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
|
||||
|
||||
Reference in New Issue
Block a user