v4.0.0-beta.468 (#8929)

This commit is contained in:
Andras Bacsai 2026-03-12 14:27:44 +01:00 committed by GitHub
commit 89aecc28a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 574 additions and 108 deletions

View file

@ -128,7 +128,7 @@ public function deployment_by_uuid(Request $request)
return response()->json(['message' => 'Deployment not found.'], 404); return response()->json(['message' => 'Deployment not found.'], 404);
} }
$application = $deployment->application; $application = $deployment->application;
if (! $application || data_get($application->team(), 'id') !== $teamId) { if (! $application || data_get($application->team(), 'id') !== (int) $teamId) {
return response()->json(['message' => 'Deployment not found.'], 404); return response()->json(['message' => 'Deployment not found.'], 404);
} }

View file

@ -2133,7 +2133,7 @@ private function check_git_if_build_needed()
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
], ],
[ [
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
'hidden' => true, 'hidden' => true,
'save' => 'git_commit_sha', 'save' => 'git_commit_sha',
] ]
@ -3929,7 +3929,7 @@ private function add_build_secrets_to_compose($composeFile)
private function validatePathField(string $value, string $fieldName): string private function validatePathField(string $value, string $fieldName): string
{ {
if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) { if (! preg_match(\App\Support\ValidationPatterns::FILE_PATH_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
} }
if (str_contains($value, '..')) { if (str_contains($value, '..')) {

View file

@ -73,7 +73,7 @@ class General extends Component
#[Validate(['string', 'nullable'])] #[Validate(['string', 'nullable'])]
public ?string $dockerfile = null; public ?string $dockerfile = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerfileLocation = null; public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])] #[Validate(['string', 'nullable'])]
@ -85,7 +85,7 @@ class General extends Component
#[Validate(['string', 'nullable'])] #[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null; public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerComposeLocation = null; public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])] #[Validate(['string', 'nullable'])]
@ -198,8 +198,8 @@ protected function rules(): array
'dockerfile' => 'nullable', 'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable', 'dockerRegistryImageTag' => 'nullable',
'dockerfileLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerComposeLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerCompose' => 'nullable', 'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable', 'dockerComposeRaw' => 'nullable',
'dockerfileTargetBuild' => 'nullable', 'dockerfileTargetBuild' => 'nullable',
@ -231,8 +231,8 @@ protected function messages(): array
return array_merge( return array_merge(
ValidationPatterns::combinedMessages(), ValidationPatterns::combinedMessages(),
[ [
'dockerfileLocation.regex' => 'The Dockerfile location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.', ...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'),
'dockerComposeLocation.regex' => 'The Docker Compose location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.', ...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'),
'name.required' => 'The Name field is required.', 'name.required' => 'The Name field is required.',
'gitRepository.required' => 'The Git Repository field is required.', 'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.', 'gitBranch.required' => 'The Git Branch field is required.',

View file

@ -168,7 +168,7 @@ public function submit()
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
]); ]);
if ($validator->fails()) { if ($validator->fails()) {

View file

@ -57,16 +57,6 @@ class GithubPrivateRepositoryDeployKey extends Component
private ?string $git_repository = null; private ?string $git_repository = null;
protected $rules = [
'repository_url' => ['required', 'string'],
'branch' => ['required', 'string'],
'port' => 'required|numeric',
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules() protected function rules()
{ {
return [ return [
@ -76,7 +66,7 @@ protected function rules()
'is_static' => 'required|boolean', 'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string', 'publish_directory' => 'nullable|string',
'build_pack' => 'required|string', 'build_pack' => 'required|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
]; ];
} }

View file

@ -63,16 +63,6 @@ class PublicGitRepository extends Component
public bool $new_compose_services = false; public bool $new_compose_services = false;
protected $rules = [
'repository_url' => ['required', 'string'],
'port' => 'required|numeric',
'isStatic' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules() protected function rules()
{ {
return [ return [
@ -82,7 +72,7 @@ protected function rules()
'publish_directory' => 'nullable|string', 'publish_directory' => 'nullable|string',
'build_pack' => 'required|string', 'build_pack' => 'required|string',
'base_directory' => 'nullable|string', 'base_directory' => 'nullable|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'git_branch' => ['required', 'string', new ValidGitBranch], 'git_branch' => ['required', 'string', new ValidGitBranch],
]; ];
} }

View file

@ -989,17 +989,24 @@ public function isPRDeployable(): bool
public function deploymentType() public function deploymentType()
{ {
if (isDev() && data_get($this, 'private_key_id') === 0) { $privateKeyId = data_get($this, 'private_key_id');
// Real private key (id > 0) always takes precedence
if ($privateKeyId !== null && $privateKeyId > 0) {
return 'deploy_key'; return 'deploy_key';
} }
if (! is_null(data_get($this, 'private_key_id'))) {
return 'deploy_key'; // GitHub/GitLab App source
} elseif (data_get($this, 'source')) { if (data_get($this, 'source')) {
return 'source'; return 'source';
} else {
return 'other';
} }
throw new \Exception('No deployment type found');
// Localhost key (id = 0) when no source is configured
if ($privateKeyId === 0) {
return 'deploy_key';
}
return 'other';
} }
public function could_set_build_commands(): bool public function could_set_build_commands(): bool
@ -1087,12 +1094,16 @@ public function dirOnServer()
return application_configuration_dir()."/{$this->uuid}"; return application_configuration_dir()."/{$this->uuid}";
} }
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null) public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null)
{ {
$baseDir = $this->generateBaseDir($deployment_uuid); $baseDir = $this->generateBaseDir($deployment_uuid);
$escapedBaseDir = escapeshellarg($baseDir); $escapedBaseDir = escapeshellarg($baseDir);
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
// 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"';
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // 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. // Invalid refs will cause the git checkout/fetch command to fail on the remote server.
$commitToUse = $commit ?? $this->git_commit_sha; $commitToUse = $commit ?? $this->git_commit_sha;
@ -1102,9 +1113,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_
// If shallow clone is enabled and we need a specific commit, // If shallow clone is enabled and we need a specific commit,
// we need to fetch that specific commit with depth=1 // we need to fetch that specific commit with depth=1
if ($isShallowCloneEnabled) { if ($isShallowCloneEnabled) {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" 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} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
} else { } else {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
} }
} }
if ($this->settings->is_git_submodules_enabled) { if ($this->settings->is_git_submodules_enabled) {
@ -1115,10 +1126,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_
} }
// Add shallow submodules flag if shallow clone is enabled // Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi";
} }
if ($this->settings->is_git_lfs_enabled) { if ($this->settings->is_git_lfs_enabled) {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull";
} }
return $git_clone_command; return $git_clone_command;
@ -1407,11 +1418,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$gitlabPort = $gitlabSource->custom_port ?? 22; $gitlabPort = $gitlabSource->custom_port ?? 22;
$escapedCustomRepository = escapeshellarg($customRepository); $escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $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}";
if ($only_checkout) { if ($only_checkout) {
$git_clone_command = $git_clone_command_base; $git_clone_command = $git_clone_command_base;
} else { } else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand);
} }
if ($exec_in_docker) { if ($exec_in_docker) {
$commands = collect([ $commands = collect([
@ -1477,11 +1489,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} }
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository); $escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command_base = "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} {$escapedCustomRepository} {$escapedBaseDir}"; $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}";
if ($only_checkout) { if ($only_checkout) {
$git_clone_command = $git_clone_command_base; $git_clone_command = $git_clone_command_base;
} else { } else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand);
} }
if ($exec_in_docker) { if ($exec_in_docker) {
$commands = collect([ $commands = collect([

View file

@ -214,37 +214,7 @@ protected function isShared(): Attribute
private function get_real_environment_variables(?string $environment_variable = null, $resource = null) private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
{ {
if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) { return resolveSharedEnvironmentVariables($environment_variable, $resource);
return null;
}
$environment_variable = trim($environment_variable);
$sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/');
if ($sharedEnvsFound->isEmpty()) {
return $environment_variable;
}
foreach ($sharedEnvsFound as $sharedEnv) {
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
$id = $resource->environment->project->id;
} elseif ($type->value() === 'team') {
$id = $resource->team()->id;
}
if (is_null($id)) {
continue;
}
$environment_variable_found = SharedEnvironmentVariable::where('type', $type)->where('key', $variable)->where('team_id', $resource->team()->id)->where("{$type}_id", $id)->first();
if ($environment_variable_found) {
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $environment_variable_found->value);
}
}
return str($environment_variable)->value();
} }
private function get_environment_variables(?string $environment_variable = null): ?string private function get_environment_variables(?string $environment_variable = null): ?string

View file

@ -17,6 +17,12 @@ class ValidationPatterns
*/ */
public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u'; public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u';
/**
* Pattern for file paths (dockerfile location, docker compose location, etc.)
* Allows alphanumeric, dots, hyphens, underscores, slashes, @, ~, and +
*/
public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
/** /**
* Get validation rules for name fields * Get validation rules for name fields
*/ */
@ -81,7 +87,25 @@ public static function descriptionMessages(): array
]; ];
} }
/** /**
* Get validation rules for file path fields (dockerfile location, docker compose location)
*/
public static function filePathRules(int $maxLength = 255): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::FILE_PATH_PATTERN];
}
/**
* Get validation messages for file path fields
*/
public static function filePathMessages(string $field = 'dockerfileLocation', string $label = 'Dockerfile'): array
{
return [
"{$field}.regex" => "The {$label} location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, underscores, slashes, @, ~, and +.",
];
}
/**
* Get combined validation messages for both name and description fields * Get combined validation messages for both name and description fields
*/ */
public static function combinedMessages(): array public static function combinedMessages(): array

View file

@ -111,13 +111,6 @@ public function execute_remote_command(...$commands)
$attempt++; $attempt++;
$delay = $this->calculateRetryDelay($attempt - 1); $delay = $this->calculateRetryDelay($attempt - 1);
// Track SSH retry event in Sentry
$this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
'command' => $this->redact_sensitive_info($command),
'trait' => 'ExecuteRemoteCommand',
]);
// Add log entry for the retry // Add log entry for the retry
if (isset($this->application_deployment_queue)) { if (isset($this->application_deployment_queue)) {
$this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);

View file

@ -134,8 +134,8 @@ function sharedDataApplications()
'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'docker_compose' => 'string|nullable', 'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable', 'docker_compose_custom_start_command' => 'string|nullable',

View file

@ -1294,6 +1294,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Otherwise keep empty string as-is // Otherwise keep empty string as-is
} }
// Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}
// Without this, literal {{...}} strings end up in the compose environment: section,
// which takes precedence over the resolved values in the .env file (env_file:)
if (is_string($value) && str_contains($value, '{{')) {
$value = resolveSharedEnvironmentVariables($value, $resource);
}
return $value; return $value;
}); });
} }
@ -2558,6 +2565,13 @@ function serviceParser(Service $resource): Collection
// Otherwise keep empty string as-is // Otherwise keep empty string as-is
} }
// Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}
// Without this, literal {{...}} strings end up in the compose environment: section,
// which takes precedence over the resolved values in the .env file (env_file:)
if (is_string($value) && str_contains($value, '{{')) {
$value = resolveSharedEnvironmentVariables($value, $resource);
}
return $value; return $value;
}); });
} }

View file

@ -3972,3 +3972,49 @@ function downsampleLTTB(array $data, int $threshold): array
return $sampled; return $sampled;
} }
/**
* Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}.
*
* This is the canonical implementation used by both EnvironmentVariable::realValue and the compose parsers
* to ensure shared variable references are replaced with their actual values.
*/
function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
{
if (is_null($value) || $value === '' || is_null($resource)) {
return $value;
}
$value = trim($value);
$sharedEnvsFound = str($value)->matchAll('/{{(.*?)}}/');
if ($sharedEnvsFound->isEmpty()) {
return $value;
}
foreach ($sharedEnvsFound as $sharedEnv) {
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
$id = null;
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
$id = $resource->environment->project->id;
} elseif ($type->value() === 'team') {
$id = $resource->team()->id;
}
if (is_null($id)) {
continue;
}
$found = \App\Models\SharedEnvironmentVariable::where('type', $type)
->where('key', $variable)
->where('team_id', $resource->team()->id)
->where("{$type}_id", $id)
->first();
if ($found) {
$value = str($value)->replace("{{{$sharedEnv}}}", $found->value);
}
}
return str($value)->value();
}

View file

@ -2,7 +2,7 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.467', 'version' => '4.0.0-beta.468',
'helper_version' => '1.0.12', 'helper_version' => '1.0.12',
'realtime_version' => '1.0.11', 'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),

View file

@ -99,6 +99,21 @@ public function run(): void
CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"]
', ',
]); ]);
Application::create([
'uuid' => 'github-deploy-key',
'name' => 'GitHub Deploy Key Example',
'fqdn' => 'http://github-deploy-key.127.0.0.1.sslip.io',
'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '80',
'environment_id' => 1,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 0,
'source_type' => GithubApp::class,
'private_key_id' => 1,
]);
Application::create([ Application::create([
'uuid' => 'gitlab-deploy-key', 'uuid' => 'gitlab-deploy-key',
'name' => 'GitLab Deploy Key Example', 'name' => 'GitLab Deploy Key Example',

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.467" "version": "4.0.0-beta.468"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.468" "version": "4.0.0-beta.469"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"

View file

@ -8,6 +8,7 @@
rafId: null, rafId: null,
scrollDebounce: null, scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true', colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || {error: true, warning: true, debug: true, info: true},
searchQuery: '', searchQuery: '',
matchCount: 0, matchCount: 0,
containerName: '{{ $container ?? "logs" }}', containerName: '{{ $container ?? "logs" }}',
@ -70,6 +71,17 @@
} }
}, 150); }, 150);
}, },
getLogLevel(content) {
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error';
if (/\b(warn|warning|wrn|caution)\b/.test(content)) return 'warning';
if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug';
return 'info';
},
toggleLogFilter(level) {
this.logFilters[level] = !this.logFilters[level];
localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters));
this.applySearch();
},
toggleColorLogs() { toggleColorLogs() {
this.colorLogs = !this.colorLogs; this.colorLogs = !this.colorLogs;
localStorage.setItem('coolify-color-logs', this.colorLogs); localStorage.setItem('coolify-color-logs', this.colorLogs);
@ -81,17 +93,11 @@
const lines = logs.querySelectorAll('[data-log-line]'); const lines = logs.querySelectorAll('[data-log-line]');
lines.forEach(line => { lines.forEach(line => {
const content = (line.dataset.logContent || '').toLowerCase(); const content = (line.dataset.logContent || '').toLowerCase();
const level = this.getLogLevel(content);
line.dataset.logLevel = level;
line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info'); line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info');
if (!this.colorLogs) return; if (!this.colorLogs) return;
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) { line.classList.add('log-' + level);
line.classList.add('log-error');
} else if (/\b(warn|warning|wrn|caution)\b/.test(content)) {
line.classList.add('log-warning');
} else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) {
line.classList.add('log-debug');
} else if (/\b(info|inf|notice)\b/.test(content)) {
line.classList.add('log-info');
}
}); });
}, },
hasActiveLogSelection() { hasActiveLogSelection() {
@ -118,7 +124,10 @@
lines.forEach(line => { lines.forEach(line => {
const content = (line.dataset.logContent || '').toLowerCase(); const content = (line.dataset.logContent || '').toLowerCase();
const textSpan = line.querySelector('[data-line-text]'); const textSpan = line.querySelector('[data-line-text]');
const matches = !query || content.includes(query); const level = line.dataset.logLevel || this.getLogLevel(content);
const passesFilter = this.logFilters[level] !== false;
const matchesSearch = !query || content.includes(query);
const matches = passesFilter && matchesSearch;
line.classList.toggle('hidden', !matches); line.classList.toggle('hidden', !matches);
if (matches && query) count++; if (matches && query) count++;
@ -389,6 +398,52 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /> d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg> </svg>
</button> </button>
<div x-data="{ filterOpen: false }" class="relative">
<button x-on:click="filterOpen = !filterOpen" title="Filter Log Levels"
:class="Object.values(logFilters).some(v => !v) ? '!text-warning' : ''"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</button>
<div x-show="filterOpen" x-on:click.away="filterOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-neutral-200 dark:ring-coolgray-300 focus:outline-none">
<div class="py-1">
<label class="flex items-center gap-2 px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300 cursor-pointer select-none">
<input type="checkbox" :checked="logFilters.error" x-on:change="toggleLogFilter('error')"
class="rounded border-gray-300 dark:border-gray-600 text-warning focus:ring-warning dark:bg-coolgray-300" />
<span class="w-2.5 h-2.5 rounded-full bg-red-500"></span>
Error
</label>
<label class="flex items-center gap-2 px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300 cursor-pointer select-none">
<input type="checkbox" :checked="logFilters.warning" x-on:change="toggleLogFilter('warning')"
class="rounded border-gray-300 dark:border-gray-600 text-warning focus:ring-warning dark:bg-coolgray-300" />
<span class="w-2.5 h-2.5 rounded-full bg-yellow-500"></span>
Warning
</label>
<label class="flex items-center gap-2 px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300 cursor-pointer select-none">
<input type="checkbox" :checked="logFilters.debug" x-on:change="toggleLogFilter('debug')"
class="rounded border-gray-300 dark:border-gray-600 text-warning focus:ring-warning dark:bg-coolgray-300" />
<span class="w-2.5 h-2.5 rounded-full bg-purple-500"></span>
Debug
</label>
<label class="flex items-center gap-2 px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300 cursor-pointer select-none">
<input type="checkbox" :checked="logFilters.info" x-on:change="toggleLogFilter('info')"
class="rounded border-gray-300 dark:border-gray-600 text-warning focus:ring-warning dark:bg-coolgray-300" />
<span class="w-2.5 h-2.5 rounded-full bg-blue-500"></span>
Info
</label>
</div>
</div>
</div>
<button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''" <button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''"
x-on:click="toggleScroll" x-on:click="toggleScroll"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">

View file

@ -23,7 +23,7 @@ class="flex flex-col h-full gap-8 sm:flex-row">
</div> </div>
<div class="md:w-96"> <div class="md:w-96">
<x-forms.checkbox instantSave id="do_not_track" <x-forms.checkbox instantSave id="do_not_track"
helper="Opt out of reporting this instance to coolify.io's installation count. No other data is collected." helper="Opt out of anonymous usage tracking. When enabled, this instance will not report to coolify.io's installation count and will not send error reports to help improve Coolify."
label="Do Not Track" /> label="Do Not Track" />
</div> </div>
<h4 class="pt-4">DNS Settings</h4> <h4 class="pt-4">DNS Settings</h4>

View file

@ -85,4 +85,62 @@
expect($result)->not->toContain('advice.detachedHead=false checkout'); expect($result)->not->toContain('advice.detachedHead=false checkout');
}); });
test('setGitImportSettings uses provided git_ssh_command for fetch', function () {
$this->application->settings->is_git_shallow_clone_enabled = true;
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
$sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22222 -o Port=22222 -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',
commit: $rollbackCommit,
git_ssh_command: $sshCommand,
);
expect($result)
->toContain('-i /root/.ssh/id_rsa" git fetch --depth=1 origin')
->toContain($rollbackCommit);
});
test('setGitImportSettings uses provided git_ssh_command for submodule update', function () {
$this->application->settings->is_git_submodules_enabled = true;
$sshCommand = 'GIT_SSH_COMMAND="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',
git_ssh_command: $sshCommand,
);
expect($result)
->toContain('-i /root/.ssh/id_rsa" git submodule update --init --recursive');
});
test('setGitImportSettings uses provided git_ssh_command for lfs pull', function () {
$this->application->settings->is_git_lfs_enabled = true;
$sshCommand = 'GIT_SSH_COMMAND="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',
git_ssh_command: $sshCommand,
);
expect($result)->toContain('-i /root/.ssh/id_rsa" git lfs pull');
});
test('setGitImportSettings uses default ssh command when git_ssh_command not provided', function () {
$this->application->settings->is_git_lfs_enabled = true;
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: true,
);
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git lfs pull')
->not->toContain('-i /root/.ssh/id_rsa');
});
}); });

View file

@ -91,6 +91,28 @@
->toBe('/docker/Dockerfile.prod'); ->toBe('/docker/Dockerfile.prod');
}); });
test('allows path with @ symbol for scoped packages', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/packages/@intlayer/mcp/Dockerfile', 'dockerfile_location'))
->toBe('/packages/@intlayer/mcp/Dockerfile');
});
test('allows path with tilde and plus characters', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/build~v1/c++/Dockerfile', 'dockerfile_location'))
->toBe('/build~v1/c++/Dockerfile');
});
test('allows valid compose file path', function () { test('allows valid compose file path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class); $job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField'); $method = $job->getMethod('validatePathField');
@ -147,6 +169,17 @@
expect($validator->fails())->toBeFalse(); expect($validator->fails())->toBeFalse();
}); });
test('dockerfile_location validation allows paths with @ for scoped packages', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/packages/@intlayer/mcp/Dockerfile'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeFalse();
});
}); });
describe('sharedDataApplications rules survive array_merge in controller', function () { describe('sharedDataApplications rules survive array_merge in controller', function () {
@ -164,7 +197,7 @@
// The merged rules for docker_compose_location should be the safe regex, not just 'string' // The merged rules for docker_compose_location should be the safe regex, not just 'string'
expect($merged['docker_compose_location'])->toBeArray(); expect($merged['docker_compose_location'])->toBeArray();
expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/'); expect($merged['docker_compose_location'])->toContain('regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN);
}); });
}); });

View file

@ -0,0 +1,94 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create token manually since User::createToken relies on session('currentTeam')
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
});
describe('GET /api/v1/deployments/{uuid}', function () {
test('returns 401 when not authenticated', function () {
$response = $this->getJson('/api/v1/deployments/fake-uuid');
$response->assertUnauthorized();
});
test('returns 404 when deployment not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/deployments/non-existent-uuid');
$response->assertNotFound();
$response->assertJson(['message' => 'Deployment not found.']);
});
test('returns deployment when uuid is valid and belongs to team', function () {
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'test-deploy-uuid',
'application_id' => $this->application->id,
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}");
$response->assertSuccessful();
$response->assertJsonFragment(['deployment_uuid' => 'test-deploy-uuid']);
});
test('returns 404 when deployment belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'environment_id' => $otherEnvironment->id,
]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'other-team-deploy-uuid',
'application_id' => $otherApplication->id,
'server_id' => $otherServer->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}");
$response->assertNotFound();
});
});

View file

@ -0,0 +1,128 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
});
test('resolveSharedEnvironmentVariables resolves environment-scoped variable', function () {
SharedEnvironmentVariable::create([
'key' => 'DRAGONFLY_URL',
'value' => 'redis://dragonfly:6379',
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
$resolved = resolveSharedEnvironmentVariables('{{environment.DRAGONFLY_URL}}', $this->application);
expect($resolved)->toBe('redis://dragonfly:6379');
});
test('resolveSharedEnvironmentVariables resolves project-scoped variable', function () {
SharedEnvironmentVariable::create([
'key' => 'DB_HOST',
'value' => 'postgres.internal',
'type' => 'project',
'project_id' => $this->project->id,
'team_id' => $this->team->id,
]);
$resolved = resolveSharedEnvironmentVariables('{{project.DB_HOST}}', $this->application);
expect($resolved)->toBe('postgres.internal');
});
test('resolveSharedEnvironmentVariables resolves team-scoped variable', function () {
SharedEnvironmentVariable::create([
'key' => 'GLOBAL_API_KEY',
'value' => 'sk-123456',
'type' => 'team',
'team_id' => $this->team->id,
]);
$resolved = resolveSharedEnvironmentVariables('{{team.GLOBAL_API_KEY}}', $this->application);
expect($resolved)->toBe('sk-123456');
});
test('resolveSharedEnvironmentVariables returns original when no match found', function () {
$resolved = resolveSharedEnvironmentVariables('{{environment.NONEXISTENT}}', $this->application);
expect($resolved)->toBe('{{environment.NONEXISTENT}}');
});
test('resolveSharedEnvironmentVariables handles null and empty values', function () {
expect(resolveSharedEnvironmentVariables(null, $this->application))->toBeNull();
expect(resolveSharedEnvironmentVariables('', $this->application))->toBe('');
expect(resolveSharedEnvironmentVariables('plain-value', $this->application))->toBe('plain-value');
});
test('resolveSharedEnvironmentVariables resolves multiple variables in one string', function () {
SharedEnvironmentVariable::create([
'key' => 'HOST',
'value' => 'myhost',
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
SharedEnvironmentVariable::create([
'key' => 'PORT',
'value' => '6379',
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
$resolved = resolveSharedEnvironmentVariables('redis://{{environment.HOST}}:{{environment.PORT}}', $this->application);
expect($resolved)->toBe('redis://myhost:6379');
});
test('resolveSharedEnvironmentVariables handles spaces in pattern', function () {
SharedEnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => 'resolved-value',
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
$resolved = resolveSharedEnvironmentVariables('{{ environment.MY_VAR }}', $this->application);
expect($resolved)->toBe('resolved-value');
});
test('EnvironmentVariable real_value still resolves shared variables after refactor', function () {
SharedEnvironmentVariable::create([
'key' => 'DRAGONFLY_URL',
'value' => 'redis://dragonfly:6379',
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
$env = EnvironmentVariable::create([
'key' => 'REDIS_URL',
'value' => '{{environment.DRAGONFLY_URL}}',
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
expect($env->real_value)->toBe('redis://dragonfly:6379');
});

View file

@ -1,11 +1,54 @@
<?php <?php
use App\Models\Application; use App\Models\Application;
use App\Models\GithubApp;
it('treats zero private key id as deploy key', function () { it('returns deploy_key when private_key_id is a real key', function () {
$application = new Application(); $application = new Application;
$application->private_key_id = 5;
expect($application->deploymentType())->toBe('deploy_key');
});
it('returns deploy_key when private_key_id is a real key even with source', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->private_key_id = 5;
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(5);
expect($application->deploymentType())->toBe('deploy_key');
});
it('returns source when private_key_id is null and source exists', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->private_key_id = null;
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
expect($application->deploymentType())->toBe('source');
});
it('returns source when private_key_id is zero and source exists', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->private_key_id = 0;
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(0);
expect($application->deploymentType())->toBe('source');
});
it('returns deploy_key when private_key_id is zero and no source', function () {
$application = new Application;
$application->private_key_id = 0; $application->private_key_id = 0;
$application->source = null; $application->source = null;
expect($application->deploymentType())->toBe('deploy_key'); expect($application->deploymentType())->toBe('deploy_key');
}); });
it('returns other when private_key_id is null and no source', function () {
$application = Mockery::mock(Application::class)->makePartial();
$application->shouldReceive('getAttribute')->with('source')->andReturn(null);
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
expect($application->deploymentType())->toBe('other');
});

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.467" "version": "4.0.0-beta.468"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.468" "version": "4.0.0-beta.469"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"