Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next

This commit is contained in:
Andras Bacsai
2026-10-26 12:12:20 +01:00
19 changed files with 756 additions and 10 deletions
+2
View File
@@ -17,10 +17,12 @@
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors.
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
- A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env.
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
- The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:<version>-dind` container); newer clients can reject data from older daemons.
- Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree.
- To assert that the unsaved-changes bar shows, check `el.classList.contains('is-dirty')`. Playwright treats the hidden bar (`opacity-0`) as visible, and its class attribute always contains the text `is-dirty` from Tailwind variants.
- In browser tests, address `x-forms.input` fields by `name` (`type('fieldName', ...)`); its HTML `id` is not always the Livewire property, so `#property` waits forever.
- Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`.
- In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`.
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
@@ -375,6 +375,8 @@ class ApplicationsController extends Controller
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host (not github.com).'],
'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
@@ -1337,7 +1339,7 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1372,6 +1374,13 @@ class ApplicationsController extends Controller
], 422);
}
if ($type !== 'public' && $request->hasAny(['git_https_username', 'git_https_password'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['git_https_password' => 'HTTPS credentials can only be used with a public or custom Git host repository (POST /applications/public).'],
], 422);
}
if ($request->filled('docker_compose_raw')) {
try {
if (! is_string($request->input('docker_compose_raw'))) {
@@ -1641,6 +1650,20 @@ class ApplicationsController extends Controller
}
$repository_url_parsed = Url::fromString($gitRepository);
$git_host = $repository_url_parsed->getHost();
if ($request->filled('git_https_password')) {
if ($git_host === 'github.com') {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['git_https_password' => 'HTTPS credentials are not used for github.com. Use a GitHub App (POST /applications/private-github-app) for a private GitHub repository.'],
], 422);
}
$application->git_https_password = $request->input('git_https_password');
} elseif ($request->filled('git_https_username')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'],
], 422);
}
if ($git_host === 'github.com') {
$application->source_type = GithubApp::class;
$application->source_id = GithubApp::find(0)->id;
@@ -3154,6 +3177,8 @@ class ApplicationsController extends Controller
'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.'],
'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -3360,7 +3385,7 @@ class ApplicationsController extends Controller
$dnsCleanup = app(ManagedDnsRecordCleanup::class);
$previousDnsHostnames = $dnsCleanup->hostnamesOf($application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS];
$validationRules = [
'name' => 'string|max:255',
@@ -3505,6 +3530,21 @@ class ApplicationsController extends Controller
}
}
if ($request->hasAny(['git_https_username', 'git_https_password'])) {
if ($application->deploymentType() !== 'other') {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['git_https_password' => 'HTTPS credentials can only be used by applications that clone a repository without a Git source or deploy key.'],
], 422);
}
if ($request->filled('git_https_username') && ! $request->filled('git_https_password') && blank($application->git_https_password)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'],
], 422);
}
}
$applicationSettings = $this->applicationSettingsFromRequest($request);
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
return $prefixValidation;
@@ -3764,6 +3804,12 @@ class ApplicationsController extends Controller
$requestHasNoindexDomains = $request->has('noindex_domains');
data_forget($data, 'noindex_domains');
$application->fill($data);
if ($request->exists('git_https_password')) {
$application->git_https_password = $request->filled('git_https_password') ? $request->input('git_https_password') : null;
if (blank($application->git_https_password)) {
$application->git_https_username = null;
}
}
if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) {
$application->source_id = $githubApp->id;
$application->source_type = $githubApp->getMorphClass();
+4 -1
View File
@@ -3103,7 +3103,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes";
}
return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
$gitConfigOption = $this->application->gitHttpsCredentialConfigOption();
$gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git';
return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
}
private function check_git_if_build_needed()
+43 -1
View File
@@ -9,6 +9,7 @@ use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Rules\ValidGitBranch;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -40,6 +41,18 @@ class Source extends Component
#[Locked]
public $sources;
#[Validate(['nullable', 'string', 'max:255', 'regex:/^[^:\r\n]*$/'])]
public ?string $gitHttpsUsername = null;
/**
* Write-only: the saved password or token is never sent to the browser.
*/
#[Validate(['nullable', 'string', 'max:2048', 'regex:/^[^\r\n]*$/'])]
public ?string $gitHttpsPassword = null;
#[Locked]
public bool $hasGitHttpsPassword = false;
public function mount()
{
try {
@@ -70,12 +83,19 @@ class Source extends Component
{
if ($toModel) {
$this->validate();
$this->application->update([
$this->application->fill([
'git_repository' => $this->gitRepository,
'git_branch' => $this->gitBranch,
'git_commit_sha' => $this->gitCommitSha,
'private_key_id' => $this->privateKeyId,
]);
if (filled($this->gitHttpsPassword)) {
$this->application->git_https_username = $this->gitHttpsUsername;
$this->application->git_https_password = $this->gitHttpsPassword;
} elseif ($this->hasGitHttpsPassword) {
$this->application->git_https_username = $this->gitHttpsUsername;
}
$this->application->save();
// Refresh to get the trimmed values from the model
$this->application->refresh();
$this->syncData(false);
@@ -85,6 +105,9 @@ class Source extends Component
$this->gitCommitSha = $this->application->git_commit_sha;
$this->privateKeyId = $this->application->private_key_id;
$this->privateKeyName = data_get($this->application, 'private_key.name');
$this->gitHttpsUsername = $this->application->git_https_username;
$this->gitHttpsPassword = null;
$this->hasGitHttpsPassword = filled($this->application->git_https_password);
}
}
@@ -138,6 +161,9 @@ class Source extends Component
if (str($this->gitCommitSha)->isEmpty()) {
$this->gitCommitSha = 'HEAD';
}
if (filled($this->gitHttpsUsername) && blank($this->gitHttpsPassword) && ! $this->hasGitHttpsPassword) {
throw ValidationException::withMessages(['gitHttpsPassword' => 'Enter the password or token for this username.']);
}
$this->syncData(true);
$this->dispatch('success', 'Application source updated!');
$this->dispatch('configurationChanged');
@@ -146,6 +172,22 @@ class Source extends Component
}
}
public function removeGitHttpsCredential()
{
try {
$this->authorize('update', $this->application);
$this->application->forceFill([
'git_https_username' => null,
'git_https_password' => null,
])->save();
$this->syncData();
$this->dispatch('success', 'HTTPS credentials removed.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function changeSource($sourceId, $sourceType)
{
try {
+5
View File
@@ -361,6 +361,11 @@ class LogDrains extends Component
'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'],
'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'],
]),
'cloudwatch' => $this->validate([
'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'],
'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'],
'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'],
]),
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
+1 -1
View File
@@ -46,7 +46,7 @@ trait BuildsResponse
'clickhouse_admin_password',
// app/env secrets
'value', 'real_value', 'http_basic_auth_password',
'value', 'real_value', 'http_basic_auth_password', 'git_https_password',
// free-form commands / configurations can embed credentials
'git_full_url',
+63 -4
View File
@@ -54,6 +54,7 @@ use Symfony\Component\Yaml\Yaml;
'git_branch' => ['type' => 'string', 'description' => 'Git branch.'],
'git_commit_sha' => ['type' => 'string', 'description' => 'Git commit SHA.'],
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']],
@@ -160,6 +161,8 @@ class Application extends BaseModel
'git_branch',
'git_commit_sha',
'git_full_url',
'git_https_username',
'git_https_password',
'docker_registry_image_name',
'docker_registry_image_tag',
'build_pack',
@@ -262,6 +265,7 @@ class Application extends BaseModel
*/
protected $hidden = [
'http_basic_auth_password',
'git_https_password',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
@@ -278,6 +282,7 @@ class Application extends BaseModel
{
return [
'http_basic_auth_password' => 'encrypted',
'git_https_password' => 'encrypted',
'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class,
'manual_webhook_secret_github' => 'encrypted',
'manual_webhook_secret_gitlab' => 'encrypted',
@@ -1801,6 +1806,10 @@ class Application extends BaseModel
if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$gitConfigOption = $this->gitHttpsCredentialConfigOption();
if ($gitConfigOption) {
$base_command = "git {$gitConfigOption} ls-remote";
}
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) {
@@ -1817,6 +1826,53 @@ class Application extends BaseModel
}
}
/**
* Tells if the application clones a private repository from a custom Git host over HTTP(S) with a username and a password or token.
*/
public function usesGitHttpsCredential(): bool
{
return filled($this->git_https_password)
&& $this->deploymentType() === 'other'
&& $this->gitHttpsCredentialOrigin() !== null;
}
/**
* The value of the Basic auth header for the HTTPS credential, also used to redact it from logs.
*/
public function gitHttpsCredentialBasicToken(): ?string
{
if (blank($this->git_https_password)) {
return null;
}
return base64_encode(($this->git_https_username ?? '').':'.$this->git_https_password);
}
/**
* A `git -c` option that sends the HTTPS credential as a Basic auth header, only to the host of the repository.
* Git does not save `-c` options in .git/config, and it passes them on to submodule and Git LFS commands.
*/
public function gitHttpsCredentialConfigOption(): ?string
{
if (! $this->usesGitHttpsCredential()) {
return null;
}
return '-c '.escapeshellarg("http.{$this->gitHttpsCredentialOrigin()}.extraHeader=Authorization: Basic {$this->gitHttpsCredentialBasicToken()}");
}
private function gitHttpsCredentialOrigin(): ?string
{
$url = parse_url((string) $this->git_repository);
$scheme = strtolower($url['scheme'] ?? '');
if (! in_array($scheme, ['http', 'https'], true) || blank($url['host'] ?? null)) {
return null;
}
$port = isset($url['port']) ? ":{$url['port']}" : '';
return "{$scheme}://{$url['host']}{$port}/";
}
private function gitCommand(string $command): array
{
return [
@@ -2123,8 +2179,11 @@ class Application extends BaseModel
if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$gitConfigOption = $this->gitHttpsCredentialConfigOption();
$gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git';
$git_clone_command = str_replace('git clone', "{$gitCommand} clone", $git_clone_command);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout);
$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) {
@@ -2135,7 +2194,7 @@ class Application extends BaseModel
} else {
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -2143,14 +2202,14 @@ class Application extends BaseModel
} else {
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOption);
}
}
+5
View File
@@ -462,6 +462,11 @@ class EnvironmentVariable extends BaseModel
}
}
if ($resource instanceof Application && filled($resource->git_https_password)) {
$secrets['locked'][] = $resource->git_https_password;
$secrets['locked'][] = $resource->gitHttpsCredentialBasicToken();
}
$variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables);
if ($includePreview && $resource instanceof Application) {
$variables = $variables->merge($resource->environment_variables_preview ?? []);
@@ -900,6 +900,7 @@ class ServerTransferExporter
// Ensure hidden encrypted fields are included as plaintext.
foreach ([
'http_basic_auth_password',
'git_https_password',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
+2
View File
@@ -189,6 +189,8 @@ function sharedDataApplications(?Application $application = null): array
return [
'git_repository' => 'string',
'git_branch' => ['string', new ValidGitBranch],
'git_https_username' => ['string', 'nullable', 'max:255', 'regex:/^[^:\r\n]*$/'],
'git_https_password' => ['string', 'nullable', 'max:2048', 'regex:/^[^\r\n]*$/'],
'build_pack' => Rule::enum(BuildPackTypes::class),
'is_static' => 'boolean',
'is_spa' => 'boolean',
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->string('git_https_username')->nullable();
$table->text('git_https_password')->nullable();
});
}
public function down(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn(['git_https_username', 'git_https_password']);
});
}
};
+25
View File
@@ -565,6 +565,16 @@
"type": "string",
"description": "The git branch."
},
"git_https_username": {
"type": "string",
"nullable": true,
"description": "Username for HTTPS access to a private repository on a custom Git host (not github.com)."
},
"git_https_password": {
"type": "string",
"nullable": true,
"description": "Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned."
},
"build_pack": {
"type": "string",
"enum": [
@@ -3639,6 +3649,16 @@
"type": "string",
"description": "The git branch."
},
"git_https_username": {
"type": "string",
"nullable": true,
"description": "Username for HTTPS access to a private repository on a custom Git host. Send null to remove it."
},
"git_https_password": {
"type": "string",
"nullable": true,
"description": "Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials."
},
"ports_exposes": {
"type": "string",
"description": "The ports to expose."
@@ -24349,6 +24369,11 @@
"nullable": true,
"description": "Git full URL."
},
"git_https_username": {
"type": "string",
"nullable": true,
"description": "Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned."
},
"docker_registry_image_name": {
"type": "string",
"nullable": true,
+20
View File
@@ -355,6 +355,14 @@ paths:
git_branch:
type: string
description: 'The git branch.'
git_https_username:
type: string
nullable: true
description: 'Username for HTTPS access to a private repository on a custom Git host (not github.com).'
git_https_password:
type: string
nullable: true
description: 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.'
build_pack:
type: string
enum: [nixpacks, railpack, static, dockerfile, dockercompose]
@@ -2407,6 +2415,14 @@ paths:
git_branch:
type: string
description: 'The git branch.'
git_https_username:
type: string
nullable: true
description: 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.'
git_https_password:
type: string
nullable: true
description: 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.'
ports_exposes:
type: string
description: 'The ports to expose.'
@@ -15489,6 +15505,10 @@ components:
type: string
nullable: true
description: 'Git full URL.'
git_https_username:
type: string
nullable: true
description: 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.'
docker_registry_image_name:
type: string
nullable: true
@@ -42,6 +42,30 @@
:canResource="$application" />
</div>
</x-application.settings-section>
@if ($application->deploymentType() === 'other')
<x-application.settings-section title="HTTPS credentials"
description="The username and password or access token Coolify uses to clone a private repository from a custom Git host (Azure DevOps, Bitbucket, Gitea, and others) over HTTP(S). Coolify sends them only to the host of the repository URL and never shows the saved password or token.">
@if ($hasGitHttpsPassword)
@can('update', $application)
<x-slot:actions>
<x-forms.button type="button" isError wire:click="removeGitHttpsCredential">
Remove credentials
</x-forms.button>
</x-slot:actions>
@endcan
@endif
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="gitHttpsUsername" label="Username" autocomplete="off"
helper="Some hosts accept any username with an access token (for example Azure DevOps)."
canGate="update" :canResource="$application" />
<x-forms.input id="gitHttpsPassword" type="password" label="Password or token"
autocomplete="new-password"
placeholder="{{ $hasGitHttpsPassword ? 'Saved. Enter a new value to replace it.' : '' }}"
canGate="update" :canResource="$application" />
</div>
</x-application.settings-section>
@endif
</form>
@if (filled($privateKeyId))
@@ -0,0 +1,245 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\Process\Process;
uses(RefreshDatabase::class);
/**
* Serves the bare repositories in $root/srv with `git http-backend` (smart HTTP) and answers 401 without the Basic credentials.
*/
function startGitHttpsCredentialServer(string $root, string $credentials): array
{
file_put_contents($root.'/router.php', <<<'PHP'
<?php
if (! hash_equals('Basic '.base64_encode(getenv('GIT_TEST_CREDENTIALS')), $_SERVER['HTTP_AUTHORIZATION'] ?? '')) {
header('WWW-Authenticate: Basic realm="git"');
http_response_code(401);
return true;
}
$backend = proc_open(['git', 'http-backend'], [['pipe', 'r'], ['pipe', 'w'], ['file', '/dev/null', 'a']], $pipes, null, [
'GIT_PROJECT_ROOT' => __DIR__.'/srv',
'GIT_HTTP_EXPORT_ALL' => '1',
'GIT_PROTOCOL' => $_SERVER['HTTP_GIT_PROTOCOL'] ?? '',
'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'],
'PATH_INFO' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '',
'CONTENT_TYPE' => $_SERVER['CONTENT_TYPE'] ?? '',
'HTTP_CONTENT_ENCODING' => $_SERVER['HTTP_CONTENT_ENCODING'] ?? '',
'PATH' => getenv('PATH'),
]);
fwrite($pipes[0], file_get_contents('php://input'));
fclose($pipes[0]);
[$head, $body] = explode("\r\n\r\n", stream_get_contents($pipes[1]), 2) + [1 => ''];
proc_close($backend);
foreach (explode("\r\n", $head) as $line) {
str_starts_with($line, 'Status:') ? http_response_code((int) substr($line, 8)) : header($line);
}
echo $body;
return true;
PHP);
$socket = stream_socket_server('tcp://127.0.0.1:0');
$port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1);
fclose($socket);
$server = new Process([PHP_BINARY, '-S', "127.0.0.1:{$port}", $root.'/router.php'], $root, ['GIT_TEST_CREDENTIALS' => $credentials]);
$server->start();
for ($i = 0; $i < 100 && ! @fsockopen('127.0.0.1', $port); $i++) {
usleep(50_000);
}
return [$server, "http://127.0.0.1:{$port}"];
}
/**
* Runs the commands like a server does over SSH: one `bash -se` that reads them from stdin.
*/
function runGitHttpsCredentialCommands(string $root, string $commands): Process
{
$process = new Process(['bash', '-se'], env: ['PATH' => $root.'/bin:'.getenv('PATH'), 'GIT_TERMINAL_PROMPT' => '0']);
$process->setInput($commands."\n");
$process->setTimeout(60);
$process->run();
return $process;
}
function gitHttpsCredentialApplication(string $repository, array $attributes = []): Application
{
$application = Application::factory()->create([
'git_repository' => $repository,
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
'build_pack' => 'dockercompose',
'base_directory' => '/',
'docker_compose_location' => '/docker-compose.yml',
...$attributes,
]);
$application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'plain-test-value'])->save();
$application->settings->update(['is_git_submodules_enabled' => true, 'is_git_lfs_enabled' => false]);
return $application->fresh();
}
function gitHttpsCredentialNonRootServer(): Server
{
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$server->shouldReceive('setAttribute')->andReturnSelf();
return $server;
}
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->root = sys_get_temp_dir().'/coolify-git-https-'.bin2hex(random_bytes(4));
mkdir($this->root.'/bin', 0755, true);
mkdir($this->root.'/srv');
mkdir($this->root.'/work');
file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n");
// Runs `docker exec <container> bash -c '...'` on the host, so the helper container commands run unchanged.
file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nshift 2\nexec \"\$@\"\n");
chmod($this->root.'/bin/sudo', 0755);
chmod($this->root.'/bin/docker', 0755);
[$this->server, $this->baseUrl] = startGitHttpsCredentialServer($this->root, 'deploy-bot:plain-test-value');
$git = fn (string $dir, array $args) => (new Process(['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', '-c', 'protocol.file.allow=always', ...$args], $dir))->mustRun();
mkdir($this->root.'/sub');
file_put_contents($this->root.'/sub/lib.txt', 'submodule file');
$git($this->root.'/sub', ['init', '-q', '-b', 'main']);
$git($this->root.'/sub', ['add', '-A']);
$git($this->root.'/sub', ['commit', '-qm', 'sub']);
$git($this->root.'/sub', ['clone', '-q', '--bare', '.', $this->root.'/srv/sub.git']);
mkdir($this->root.'/src');
$this->compose = "services:\n web:\n image: nginx\n";
file_put_contents($this->root.'/src/docker-compose.yml', $this->compose);
$git($this->root.'/src', ['init', '-q', '-b', 'main']);
$git($this->root.'/src', ['submodule', 'add', '-q', $this->root.'/srv/sub.git', 'lib']);
$git($this->root.'/src', ['config', '-f', '.gitmodules', 'submodule.lib.url', $this->baseUrl.'/sub.git']);
$git($this->root.'/src', ['add', '-A']);
$git($this->root.'/src', ['commit', '-qm', 'app']);
$this->commit = trim($git($this->root.'/src', ['rev-parse', 'HEAD'])->getOutput());
file_put_contents($this->root.'/src/pr.txt', 'pull request');
$git($this->root.'/src', ['add', '-A']);
$git($this->root.'/src', ['commit', '-qm', 'pr']);
$git($this->root.'/src', ['clone', '-q', '--bare', '.', $this->root.'/srv/app.git']);
$git($this->root.'/srv/app.git', ['update-ref', 'refs/pull/7/head', 'HEAD']);
$git($this->root.'/srv/app.git', ['update-ref', 'refs/heads/main', $this->commit]);
$this->repository = $this->baseUrl.'/app.git';
});
afterEach(function () {
$this->server->stop(0);
(new Process(['rm', '-rf', $this->root]))->run();
Mockery::close();
});
test('the deployment clone uses the HTTPS credential for the repository, the commit, and submodules without saving it', function (int $pullRequestId) {
$application = gitHttpsCredentialApplication($this->repository, ['git_commit_sha' => $pullRequestId === 0 ? $this->commit : 'HEAD']);
$checkout = $this->root.'/work/checkout';
['commands' => $commands] = $application->generateGitImportCommands('deploy-uuid', pull_request_id: $pullRequestId, git_type: 'gitea', exec_in_docker: true, custom_base_dir: $checkout);
$process = runGitHttpsCredentialCommands($this->root, $commands);
expect($process->getExitCode())->toBe(0, $process->getErrorOutput());
$savedConfig = file_get_contents($checkout.'/.git/config').file_get_contents($checkout.'/.git/modules/lib/config');
expect(file_get_contents($checkout.'/lib/lib.txt'))->toBe('submodule file')
->and(file_exists($checkout.'/pr.txt'))->toBe($pullRequestId !== 0)
->and($savedConfig)->not->toContain('plain-test-value')
->and($savedConfig)->not->toContain($application->gitHttpsCredentialBasicToken())
->and($savedConfig)->not->toContain('extraHeader');
})->with([
'pinned commit' => [0],
'pull request preview' => [7],
]);
test('git fails without the right HTTPS credential', function () {
$application = gitHttpsCredentialApplication($this->repository);
$application->forceFill(['git_https_password' => 'wrong-token'])->save();
['commands' => $commands] = $application->fresh()->generateGitImportCommands('deploy-uuid', exec_in_docker: false, custom_base_dir: $this->root.'/work/checkout');
$process = runGitHttpsCredentialCommands($this->root, $commands);
expect($process->getExitCode())->not->toBe(0)
->and($process->getErrorOutput())->toContain('could not read Username')
->and(file_exists($this->root.'/work/checkout/docker-compose.yml'))->toBeFalse();
});
test('ls-remote on the server works for root and non-root users', function (bool $nonRoot) {
$application = gitHttpsCredentialApplication($this->repository);
['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid', exec_in_docker: false);
$commands = collect([$command]);
if ($nonRoot) {
$commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer()));
}
$process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n"));
expect($process->getExitCode())->toBe(0, $process->getErrorOutput())
->and($process->getOutput())->toContain("{$this->commit}\trefs/heads/main");
})->with(['root' => [false], 'non-root' => [true]]);
test('the deployment job ls-remote uses the HTTPS credential in the helper container', function () {
$application = gitHttpsCredentialApplication($this->repository);
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$reflection = new ReflectionObject($job);
foreach (['application' => $application, 'customPort' => 22, 'fullRepoUrl' => $this->repository] as $property => $value) {
$reflection->getProperty($property)->setValue($job, $value);
}
$command = $reflection->getMethod('gitLsRemoteCommand')->invoke($job, 'refs/heads/main');
$process = runGitHttpsCredentialCommands($this->root, executeInDocker('deploy-uuid', $command));
expect($process->getExitCode())->toBe(0, $process->getErrorOutput())
->and($process->getOutput())->toBe("{$this->commit}\trefs/heads/main\n");
});
test('the Compose file loads with the HTTPS credential for root and non-root users', function (bool $nonRoot) {
$application = gitHttpsCredentialApplication($this->repository);
$uuid = 'git-https-'.bin2hex(random_bytes(4));
$gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last();
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, $uuid, $gitVersion);
if ($nonRoot) {
$commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer()));
}
$process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n"));
(new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();
expect($process->getExitCode())->toBe(0, $process->getErrorOutput())
->and($process->getOutput())->toBe($this->compose);
})->with(['root' => [false], 'non-root' => [true]]);
test('git sends the HTTPS credential only to the repository host', function () {
$application = gitHttpsCredentialApplication($this->repository);
$option = $application->gitHttpsCredentialConfigOption();
$headerFor = fn (string $url) => trim(runGitHttpsCredentialCommands($this->root, "git {$option} config --get-urlmatch http.extraHeader {$url} || true")->getOutput());
expect($headerFor($this->baseUrl.'/other/repo.git'))->toBe('Authorization: Basic '.base64_encode('deploy-bot:plain-test-value'))
->and($headerFor('http://example.com/app.git'))->toBe('')
->and($headerFor(str_replace('http://', 'https://', $this->baseUrl).'/app.git'))->toBe('');
});
test('the HTTPS credential is not used for SSH URLs, deploy keys, or applications without a password', function (array $attributes) {
$application = gitHttpsCredentialApplication($this->repository);
$application->forceFill($attributes)->save();
expect($application->fresh()->gitHttpsCredentialConfigOption())->toBeNull();
})->with([
'ssh url' => [['git_repository' => 'git@example.com:org/app.git']],
'deploy key' => [['private_key_id' => 0]],
'no password' => [['git_https_password' => null]],
]);
+207
View File
@@ -0,0 +1,207 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
Storage::fake('ssh-keys');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$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,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'git_repository' => 'https://dev.azure.com/org/project/_git/app',
'git_branch' => 'main',
]);
});
function gitHttpsCredentialRawPassword(Application $application): ?string
{
return DB::table('applications')->where('id', $application->id)->value('git_https_password');
}
function gitHttpsCredentialApiToken(User $user, Team $team): string
{
session(['currentTeam' => $team]);
return $user->createToken('git-https-credential-test', ['root'])->plainTextToken;
}
test('an admin saves the HTTPS credential encrypted and the browser never gets the password', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Source::class, ['application' => $this->application])
->set('gitHttpsUsername', 'deploy-bot')
->set('gitHttpsPassword', 'azure-test-value')
->call('submit')
->assertSet('gitHttpsPassword', null)
->assertSet('hasGitHttpsPassword', true);
$application = $this->application->fresh();
expect($application->git_https_username)->toBe('deploy-bot')
->and($application->git_https_password)->toBe('azure-test-value')
->and(gitHttpsCredentialRawPassword($application))->not->toContain('azure-test-value')
->and($component->html())->not->toContain('azure-test-value');
$component->set('gitHttpsUsername', 'other-bot')->call('submit');
expect($this->application->fresh()->git_https_password)->toBe('azure-test-value')
->and($this->application->fresh()->git_https_username)->toBe('other-bot');
$component->call('removeGitHttpsCredential')->assertSet('hasGitHttpsPassword', false);
expect($this->application->fresh()->git_https_password)->toBeNull()
->and($this->application->fresh()->git_https_username)->toBeNull();
});
test('a username without a password is rejected', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(Source::class, ['application' => $this->application])
->set('gitHttpsUsername', 'deploy-bot')
->call('submit')
->assertDispatched('error');
expect($this->application->fresh()->git_https_username)->toBeNull();
});
test('a member of the application team cannot change the HTTPS credential after switching to a team they own', function () {
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'member']);
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($user->id, ['role' => 'owner']);
$this->application->forceFill(['git_https_password' => 'existing-token'])->save();
$this->actingAs($user);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Source::class, ['application' => $this->application]);
session(['currentTeam' => $otherTeam]);
$component->set('gitHttpsPassword', 'attacker-token')->call('submit');
$component->call('removeGitHttpsCredential');
expect($this->application->fresh()->git_https_password)->toBe('existing-token');
});
test('deployment logs never contain the HTTPS credential', function () {
$this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'log-canary-token'])->save();
$application = $this->application->fresh();
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => fake()->uuid(),
]);
['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid');
$deployment->addLogEntry(message: 'remote said log-canary-token', command: $command);
$logs = (string) $deployment->fresh()->logs;
expect($command)->toContain($application->gitHttpsCredentialBasicToken())
->and($logs)->not->toContain('log-canary-token')
->and($logs)->not->toContain($application->gitHttpsCredentialBasicToken());
});
test('the API creates an application with an HTTPS credential and never returns the password', function () {
$token = gitHttpsCredentialApiToken($this->owner, $this->team);
$response = $this->withToken($token)->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://git.example.com/org/private.git',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'autogenerate_domain' => false,
'git_https_username' => 'deploy-bot',
'git_https_password' => 'api-created-token',
])->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->git_https_password)->toBe('api-created-token')
->and($application->gitHttpsCredentialConfigOption())->not->toBeNull();
$this->withToken($token)->getJson("/api/v1/applications/{$application->uuid}")
->assertOk()
->assertJsonPath('git_https_username', 'deploy-bot')
->assertJsonMissingPath('git_https_password')
->assertDontSee('api-created-token');
});
test('the API rejects an HTTPS credential where it is not used', function (string $endpoint, array $payload) {
$token = gitHttpsCredentialApiToken($this->owner, $this->team);
$this->withToken($token)->postJson($endpoint, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'autogenerate_domain' => false,
...$payload,
])->assertUnprocessable()->assertJsonValidationErrors('git_https_password');
})->with([
'github.com' => ['/api/v1/applications/public', ['git_repository' => 'https://github.com/org/app', 'git_https_password' => 'token']],
'username only' => ['/api/v1/applications/public', ['git_repository' => 'https://git.example.com/org/app.git', 'git_https_username' => 'deploy-bot']],
'deploy key endpoint' => ['/api/v1/applications/private-deploy-key', ['git_repository' => 'git@git.example.com:org/app.git', 'git_https_password' => 'token']],
]);
test('the API updates and removes the HTTPS credential', function () {
$token = gitHttpsCredentialApiToken($this->owner, $this->team);
$this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_https_username' => 'deploy-bot',
'git_https_password' => 'updated-token',
])->assertOk()->assertDontSee('updated-token');
expect($this->application->fresh()->git_https_password)->toBe('updated-token');
$this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_https_password' => null,
])->assertOk();
expect($this->application->fresh()->git_https_password)->toBeNull()
->and($this->application->fresh()->git_https_username)->toBeNull();
});
test('the API rejects an HTTPS credential for an application with a deploy key', function () {
$token = gitHttpsCredentialApiToken($this->owner, $this->team);
$this->application->forceFill(['private_key_id' => 0])->save();
$this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_https_password' => 'token',
])->assertUnprocessable()->assertJsonValidationErrors('git_https_password');
expect($this->application->fresh()->git_https_password)->toBeNull();
});
test('a token of another team cannot set the HTTPS credential', function () {
$otherUser = User::factory()->create();
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$token = gitHttpsCredentialApiToken($otherUser, $otherTeam);
$this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_https_password' => 'attacker-token',
])->assertNotFound();
expect($this->application->fresh()->git_https_password)->toBeNull();
});
@@ -291,6 +291,7 @@ describe('deployment git command escaping', function () {
$instance = $job->newInstanceWithoutConstructor();
foreach ([
'application' => new Application,
'customPort' => 22,
'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #",
] as $property => $value) {
@@ -262,6 +262,7 @@ test('dry run import reports counts without creating server', function () {
});
test('round trip import preserves uuids secrets and related resources after source removal', function () {
$this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'transfer-pat-secret'])->save();
$bundle = $this->exporter->export($this->server);
$originalServerUuid = $this->server->uuid;
$originalAppUuid = $this->application->uuid;
@@ -309,7 +310,9 @@ test('round trip import preserves uuids secrets and related resources after sour
->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value')
->and($app->persistentStorages)->toHaveCount(1)
->and($app->scheduled_tasks)->toHaveCount(1)
->and($app->tags()->pluck('name')->all())->toContain('transfer-demo');
->and($app->tags()->pluck('name')->all())->toContain('transfer-demo')
->and($app->git_https_username)->toBe('deploy-bot')
->and($app->git_https_password)->toBe('transfer-pat-secret');
$db = StandalonePostgresql::where('uuid', $originalDbUuid)->first();
expect($db)->not->toBeNull()
@@ -0,0 +1,33 @@
<?php
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(fn () => Server::flushIdentityMap());
afterEach(fn () => Server::flushIdentityMap());
it('saves the HTTPS credential from the source page without showing the token again', function () {
$stack = seedBrowserResourceStack();
$application = createBrowserApplication($stack, [
'name' => 'Azure App',
'git_repository' => 'https://dev.azure.com/org/project/_git/app',
'git_branch' => 'main',
]);
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application).'/source');
$page->assertSee('HTTPS credentials')
->type('gitHttpsUsername', 'deploy-bot')
->type('gitHttpsPassword', 'browser-pat-token')
->click('button:visible:has-text("Save changes")')
->assertSee('Remove credentials')
->assertValue('input[name=gitHttpsPassword]', '')
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-git-https-credential');
expect($application->fresh()->git_https_password)->toBe('browser-pat-token')
->and($application->fresh()->git_https_username)->toBe('deploy-bot');
});