mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 02:15:51 +00:00
Merge remote-tracking branch 'origin/next' into audit-policies
# Conflicts: # app/Http/Controllers/Api/SecurityController.php # app/Http/Controllers/Api/ServersController.php # app/Livewire/Admin/Index.php # app/Livewire/Destination/Show.php # app/Livewire/NavbarDeleteTeam.php # app/Livewire/Project/Application/Previews.php # app/Livewire/Project/DeleteProject.php # app/Livewire/Project/Shared/ResourceOperations.php # app/Livewire/Server/Resources.php # app/Livewire/Server/ValidateAndInstall.php # app/Livewire/Storage/Show.php # resources/views/livewire/dashboard.blade.php # resources/views/livewire/project/application/heading.blade.php # resources/views/livewire/project/database/heading.blade.php # resources/views/livewire/project/service/heading.blade.php # resources/views/livewire/project/shared/environment-variable/show.blade.php # resources/views/livewire/project/shared/scheduled-task/show.blade.php # tests/Feature/Security/TrustHostsMiddlewareTest.php
This commit is contained in:
commit
43f33d94ad
685 files changed
+41904
-5674
No files matched your search
+286
-37
@@ -118,18 +118,124 @@ class Application extends BaseModel
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'fqdn',
|
||||
'git_repository',
|
||||
'git_branch',
|
||||
'git_commit_sha',
|
||||
'git_full_url',
|
||||
'docker_registry_image_name',
|
||||
'docker_registry_image_tag',
|
||||
'build_pack',
|
||||
'static_image',
|
||||
'install_command',
|
||||
'build_command',
|
||||
'start_command',
|
||||
'ports_exposes',
|
||||
'ports_mappings',
|
||||
'base_directory',
|
||||
'publish_directory',
|
||||
'health_check_enabled',
|
||||
'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',
|
||||
'health_check_type',
|
||||
'health_check_command',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'status',
|
||||
'preview_url_template',
|
||||
'dockerfile',
|
||||
'dockerfile_location',
|
||||
'dockerfile_target_build',
|
||||
'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',
|
||||
'docker_compose_location',
|
||||
'docker_compose_pr_location',
|
||||
'docker_compose',
|
||||
'docker_compose_pr',
|
||||
'docker_compose_raw',
|
||||
'docker_compose_pr_raw',
|
||||
'docker_compose_domains',
|
||||
'docker_compose_custom_start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
'swarm_replicas',
|
||||
'swarm_placement_constraints',
|
||||
'watch_paths',
|
||||
'redirect',
|
||||
'compose_parsing_version',
|
||||
'custom_nginx_configuration',
|
||||
'custom_network_aliases',
|
||||
'custom_healthcheck_found',
|
||||
'nixpkgsarchive',
|
||||
'is_http_basic_auth_enabled',
|
||||
'http_basic_auth_username',
|
||||
'http_basic_auth_password',
|
||||
'connect_to_docker_network',
|
||||
'force_domain_override',
|
||||
'is_container_label_escape_enabled',
|
||||
'use_build_server',
|
||||
'config_hash',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'uuid',
|
||||
'environment_id',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
'source_id',
|
||||
'source_type',
|
||||
'repository_project_id',
|
||||
'private_key_id',
|
||||
];
|
||||
|
||||
protected $appends = ['server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'manual_webhook_secret_github' => 'encrypted',
|
||||
'manual_webhook_secret_gitlab' => 'encrypted',
|
||||
'manual_webhook_secret_bitbucket' => 'encrypted',
|
||||
'manual_webhook_secret_gitea' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($application) {
|
||||
$application->manual_webhook_secret_github ??= Str::random(40);
|
||||
$application->manual_webhook_secret_gitlab ??= Str::random(40);
|
||||
$application->manual_webhook_secret_bitbucket ??= Str::random(40);
|
||||
$application->manual_webhook_secret_gitea ??= Str::random(40);
|
||||
});
|
||||
static::addGlobalScope('withRelations', function ($builder) {
|
||||
$builder->withCount([
|
||||
'additional_servers',
|
||||
@@ -177,7 +283,7 @@ class Application extends BaseModel
|
||||
}
|
||||
}
|
||||
if (count($payload) > 0) {
|
||||
$application->forceFill($payload);
|
||||
$application->fill($payload);
|
||||
}
|
||||
|
||||
// Buildpack switching cleanup logic
|
||||
@@ -390,7 +496,7 @@ class Application extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -989,17 +1095,24 @@ class Application extends BaseModel
|
||||
|
||||
public function deploymentType()
|
||||
{
|
||||
if (isDev() && data_get($this, 'private_key_id') === 0) {
|
||||
$privateKeyId = data_get($this, 'private_key_id');
|
||||
|
||||
// Real private key (id > 0) always takes precedence
|
||||
if ($privateKeyId !== null && $privateKeyId > 0) {
|
||||
return 'deploy_key';
|
||||
}
|
||||
if (! is_null(data_get($this, 'private_key_id'))) {
|
||||
return 'deploy_key';
|
||||
} elseif (data_get($this, 'source')) {
|
||||
|
||||
// GitHub/GitLab App source
|
||||
if (data_get($this, 'source')) {
|
||||
return 'source';
|
||||
} else {
|
||||
return 'other';
|
||||
}
|
||||
throw new \Exception('No deployment type found');
|
||||
|
||||
// Localhost key (id = 0) when no source is configured
|
||||
if ($privateKeyId === 0) {
|
||||
return 'deploy_key';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
public function could_set_build_commands(): bool
|
||||
@@ -1044,7 +1157,7 @@ class Application extends BaseModel
|
||||
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build);
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
} else {
|
||||
@@ -1087,12 +1200,16 @@ class Application extends BaseModel
|
||||
return application_configuration_dir()."/{$this->uuid}";
|
||||
}
|
||||
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null)
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
|
||||
// Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided,
|
||||
// so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone.
|
||||
$sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
|
||||
|
||||
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
|
||||
// Invalid refs will cause the git checkout/fetch command to fail on the remote server.
|
||||
$commitToUse = $commit ?? $this->git_commit_sha;
|
||||
@@ -1102,9 +1219,9 @@ class Application extends BaseModel
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
@@ -1115,10 +1232,10 @@ class Application extends BaseModel
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
}
|
||||
if ($this->settings->is_git_lfs_enabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull";
|
||||
}
|
||||
|
||||
return $git_clone_command;
|
||||
@@ -1134,7 +1251,7 @@ class Application extends BaseModel
|
||||
'is_accessible' => true,
|
||||
'error' => null,
|
||||
];
|
||||
} catch (\RuntimeException $ex) {
|
||||
} catch (RuntimeException $ex) {
|
||||
return [
|
||||
'is_accessible' => false,
|
||||
'error' => $ex->getMessage(),
|
||||
@@ -1163,14 +1280,15 @@ class Application extends BaseModel
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
$encodedToken = rawurlencode($github_access_token);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
} else {
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
@@ -1189,6 +1307,62 @@ class Application extends BaseModel
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->source->getMorphClass() === GitlabApp::class) {
|
||||
$gitlabSource = $this->source;
|
||||
$private_key = data_get($gitlabSource, 'privateKey.private_key');
|
||||
|
||||
if ($private_key) {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$private_key = base64_encode($private_key);
|
||||
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||
'chmod 600 /root/.ssh/id_rsa',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->deploymentType() === 'deploy_key') {
|
||||
@@ -1286,7 +1460,7 @@ class Application extends BaseModel
|
||||
$source_html_url_host = $url['host'];
|
||||
$source_html_url_scheme = $url['scheme'];
|
||||
|
||||
if ($this->source->getMorphClass() === \App\Models\GithubApp::class) {
|
||||
if ($this->source->getMorphClass() === GithubApp::class) {
|
||||
if ($this->source->is_public) {
|
||||
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
@@ -1301,13 +1475,14 @@ class Application extends BaseModel
|
||||
}
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
$encodedToken = rawurlencode($github_access_token);
|
||||
if ($exec_in_docker) {
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
} else {
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
@@ -1339,6 +1514,78 @@ class Application extends BaseModel
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->source->getMorphClass() === GitlabApp::class) {
|
||||
$gitlabSource = $this->source;
|
||||
$private_key = data_get($gitlabSource, 'privateKey.private_key');
|
||||
|
||||
if ($private_key) {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$private_key = base64_encode($private_key);
|
||||
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
|
||||
$git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||
'chmod 600 /root/.ssh/id_rsa',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($this->deploymentType() === 'deploy_key') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
@@ -1348,11 +1595,12 @@ class Application extends BaseModel
|
||||
}
|
||||
$private_key = base64_encode($private_key);
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
|
||||
$git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
@@ -1458,7 +1706,7 @@ class Application extends BaseModel
|
||||
try {
|
||||
$yaml = Yaml::parse($this->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
$services = data_get($yaml, 'services');
|
||||
|
||||
@@ -1540,7 +1788,7 @@ class Application extends BaseModel
|
||||
$fileList = collect([".$workdir$composeFile"]);
|
||||
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
|
||||
if (! $gitRemoteStatus['is_accessible']) {
|
||||
throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}");
|
||||
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
|
||||
}
|
||||
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
|
||||
$gitVersion = str($getGitVersion)->explode(' ')->last();
|
||||
@@ -1590,15 +1838,15 @@ class Application extends BaseModel
|
||||
$this->save();
|
||||
|
||||
if (str($e->getMessage())->contains('No such file')) {
|
||||
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||
throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||
}
|
||||
if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
|
||||
if ($this->deploymentType() === 'deploy_key') {
|
||||
throw new \RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.');
|
||||
throw new RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.');
|
||||
}
|
||||
throw new \RuntimeException('Repository does not exist. Please check your repository URL and try again.');
|
||||
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
|
||||
}
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
|
||||
} finally {
|
||||
// Cleanup only - restoration happens in catch block
|
||||
$commands = collect([
|
||||
@@ -1651,7 +1899,7 @@ class Application extends BaseModel
|
||||
$this->base_directory = $initialBaseDirectory;
|
||||
$this->save();
|
||||
|
||||
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||
throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,7 +1934,8 @@ class Application extends BaseModel
|
||||
|
||||
protected function buildGitCheckoutCommand($target): string
|
||||
{
|
||||
$command = "git checkout $target";
|
||||
$escapedTarget = escapeshellarg($target);
|
||||
$command = "git checkout {$escapedTarget}";
|
||||
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
$command .= ' && git submodule update --init --recursive';
|
||||
|
||||
@@ -16,6 +16,7 @@ use OpenApi\Attributes as OA;
|
||||
'application_id' => ['type' => 'string'],
|
||||
'deployment_uuid' => ['type' => 'string'],
|
||||
'pull_request_id' => ['type' => 'integer'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
|
||||
'force_rebuild' => ['type' => 'boolean'],
|
||||
'commit' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
@@ -39,9 +40,36 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class ApplicationDeploymentQueue extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'deployment_uuid',
|
||||
'pull_request_id',
|
||||
'docker_registry_image_tag',
|
||||
'force_rebuild',
|
||||
'commit',
|
||||
'status',
|
||||
'is_webhook',
|
||||
'logs',
|
||||
'current_process_id',
|
||||
'restart_only',
|
||||
'git_type',
|
||||
'server_id',
|
||||
'application_name',
|
||||
'server_name',
|
||||
'deployment_url',
|
||||
'destination_id',
|
||||
'only_this_server',
|
||||
'rollback',
|
||||
'commit_message',
|
||||
'is_api',
|
||||
'build_server_id',
|
||||
'horizon_job_id',
|
||||
'horizon_job_worker',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
@@ -10,7 +10,23 @@ class ApplicationPreview extends BaseModel
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'application_id',
|
||||
'pull_request_id',
|
||||
'pull_request_html_url',
|
||||
'pull_request_issue_comment_id',
|
||||
'fqdn',
|
||||
'status',
|
||||
'git_type',
|
||||
'docker_compose_domains',
|
||||
'docker_registry_image_tag',
|
||||
'last_online_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
@@ -37,7 +53,7 @@ class ApplicationPreview extends BaseModel
|
||||
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
|
||||
if ($persistentStorages->count() > 0) {
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +63,7 @@ class ApplicationPreview extends BaseModel
|
||||
});
|
||||
static::saving(function ($preview) {
|
||||
if ($preview->isDirty('status')) {
|
||||
$preview->forceFill(['last_online_at' => now()]);
|
||||
$preview->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -69,7 +85,7 @@ class ApplicationPreview extends BaseModel
|
||||
|
||||
public function persistentStorages()
|
||||
{
|
||||
return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource');
|
||||
return $this->morphMany(LocalPersistentVolume::class, 'resource');
|
||||
}
|
||||
|
||||
public function generate_preview_fqdn()
|
||||
@@ -166,6 +182,16 @@ class ApplicationPreview extends BaseModel
|
||||
}
|
||||
|
||||
$this->docker_compose_domains = json_encode($docker_compose_domains);
|
||||
|
||||
// Populate fqdn from generated domains so webhook notifications can read it
|
||||
$allDomains = collect($docker_compose_domains)
|
||||
->pluck('domain')
|
||||
->filter(fn ($d) => ! empty($d))
|
||||
->flatMap(fn ($d) => explode(',', $d))
|
||||
->implode(',');
|
||||
|
||||
$this->fqdn = ! empty($allDomains) ? $allDomains : null;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,43 @@ class ApplicationSetting extends Model
|
||||
'docker_images_to_keep' => 'integer',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'is_static',
|
||||
'is_git_submodules_enabled',
|
||||
'is_git_lfs_enabled',
|
||||
'is_auto_deploy_enabled',
|
||||
'is_force_https_enabled',
|
||||
'is_debug_enabled',
|
||||
'is_preview_deployments_enabled',
|
||||
'is_log_drain_enabled',
|
||||
'is_gpu_enabled',
|
||||
'gpu_driver',
|
||||
'gpu_count',
|
||||
'gpu_device_ids',
|
||||
'gpu_options',
|
||||
'is_include_timestamps',
|
||||
'is_swarm_only_worker_nodes',
|
||||
'is_raw_compose_deployment_enabled',
|
||||
'is_build_server_enabled',
|
||||
'is_consistent_container_name_enabled',
|
||||
'is_gzip_enabled',
|
||||
'is_stripprefix_enabled',
|
||||
'connect_to_docker_network',
|
||||
'custom_internal_name',
|
||||
'is_container_label_escape_enabled',
|
||||
'is_env_sorting_enabled',
|
||||
'is_container_label_readonly_enabled',
|
||||
'is_preserve_repository_enabled',
|
||||
'disable_build_cache',
|
||||
'is_spa',
|
||||
'is_git_shallow_clone_enabled',
|
||||
'is_pr_deployments_public_enabled',
|
||||
'use_build_secrets',
|
||||
'inject_build_args_to_dockerfile',
|
||||
'include_source_commit_in_build',
|
||||
'docker_images_to_keep',
|
||||
];
|
||||
|
||||
public function isStatic(): Attribute
|
||||
{
|
||||
|
||||
@@ -4,7 +4,12 @@ namespace App\Models;
|
||||
|
||||
class CloudProviderToken extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'provider',
|
||||
'token',
|
||||
'name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'token' => 'encrypted',
|
||||
|
||||
@@ -24,7 +24,8 @@ class DiscordNotificationSettings extends Model
|
||||
'backup_failure_discord_notifications',
|
||||
'scheduled_task_success_discord_notifications',
|
||||
'scheduled_task_failure_discord_notifications',
|
||||
'docker_cleanup_discord_notifications',
|
||||
'docker_cleanup_success_discord_notifications',
|
||||
'docker_cleanup_failure_discord_notifications',
|
||||
'server_disk_usage_discord_notifications',
|
||||
'server_reachable_discord_notifications',
|
||||
'server_unreachable_discord_notifications',
|
||||
|
||||
@@ -6,7 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DockerCleanupExecution extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'status',
|
||||
'message',
|
||||
'cleanup_log',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -34,7 +34,11 @@ class EmailNotificationSettings extends Model
|
||||
'backup_failure_email_notifications',
|
||||
'scheduled_task_success_email_notifications',
|
||||
'scheduled_task_failure_email_notifications',
|
||||
'docker_cleanup_success_email_notifications',
|
||||
'docker_cleanup_failure_email_notifications',
|
||||
'server_disk_usage_email_notifications',
|
||||
'server_reachable_email_notifications',
|
||||
'server_unreachable_email_notifications',
|
||||
'server_patch_email_notifications',
|
||||
'traefik_outdated_email_notifications',
|
||||
];
|
||||
|
||||
@@ -25,7 +25,12 @@ class Environment extends BaseModel
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'project_id',
|
||||
'uuid',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
@@ -58,7 +63,7 @@ class Environment extends BaseModel
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->hasMany(SharedEnvironmentVariable::class);
|
||||
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'environment');
|
||||
}
|
||||
|
||||
public function applications()
|
||||
|
||||
@@ -32,6 +32,11 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class EnvironmentVariable extends BaseModel
|
||||
{
|
||||
protected $attributes = [
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
// Core identification
|
||||
'key',
|
||||
@@ -147,6 +152,17 @@ class EnvironmentVariable extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load relationships needed for shared variable resolution
|
||||
if (! $resource->relationLoaded('environment')) {
|
||||
$resource->load('environment');
|
||||
}
|
||||
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
|
||||
$resource->load('server');
|
||||
}
|
||||
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
|
||||
$resource->load('destination.server');
|
||||
}
|
||||
|
||||
$real_value = $this->get_real_environment_variables($this->value, $resource);
|
||||
|
||||
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
|
||||
@@ -212,10 +228,57 @@ class EnvironmentVariable extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
|
||||
{
|
||||
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
|
||||
}
|
||||
|
||||
public function getResolvedValueWithServer($server = null)
|
||||
{
|
||||
if (! $this->relationLoaded('resourceable')) {
|
||||
$this->load('resourceable');
|
||||
}
|
||||
$resource = $this->resourceable;
|
||||
if (! $resource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load relationships needed for shared variable resolution
|
||||
if (! $resource->relationLoaded('environment')) {
|
||||
$resource->load('environment');
|
||||
}
|
||||
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
|
||||
$resource->load('server');
|
||||
}
|
||||
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
|
||||
$resource->load('destination.server');
|
||||
}
|
||||
|
||||
$real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server);
|
||||
|
||||
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
|
||||
if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) {
|
||||
return $real_value;
|
||||
}
|
||||
|
||||
if ($this->is_literal || $this->is_multiline) {
|
||||
$real_value = '\''.$real_value.'\'';
|
||||
} else {
|
||||
$real_value = escapeEnvVariables($real_value);
|
||||
}
|
||||
|
||||
return $real_value;
|
||||
}
|
||||
|
||||
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
|
||||
{
|
||||
if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) {
|
||||
return null;
|
||||
return $this->get_real_environment_variables_internal($environment_variable, $resource);
|
||||
}
|
||||
|
||||
private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null)
|
||||
{
|
||||
if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) {
|
||||
return $environment_variable;
|
||||
}
|
||||
$environment_variable = trim($environment_variable);
|
||||
$sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/');
|
||||
@@ -228,19 +291,32 @@ class EnvironmentVariable extends BaseModel
|
||||
continue;
|
||||
}
|
||||
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
|
||||
$id = null;
|
||||
if ($type->value() === 'environment') {
|
||||
$id = $resource->environment->id;
|
||||
} elseif ($type->value() === 'project') {
|
||||
$id = $resource->environment->project->id;
|
||||
} elseif ($type->value() === 'team') {
|
||||
$id = $resource->team()->id;
|
||||
} elseif ($type->value() === 'server') {
|
||||
if ($serverOverride) {
|
||||
$id = $serverOverride->id;
|
||||
} elseif (isset($resource->server) && $resource->server) {
|
||||
$id = $resource->server->id;
|
||||
} elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) {
|
||||
$id = $resource->destination->server->id;
|
||||
}
|
||||
}
|
||||
if (is_null($id)) {
|
||||
continue;
|
||||
}
|
||||
$environment_variable_found = SharedEnvironmentVariable::where('type', $type)->where('key', $variable)->where('team_id', $resource->team()->id)->where("{$type}_id", $id)->first();
|
||||
if ($environment_variable_found) {
|
||||
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $environment_variable_found->value);
|
||||
$found = SharedEnvironmentVariable::where('type', $type)
|
||||
->where('key', $variable)
|
||||
->where('team_id', $resource->team()->id)
|
||||
->where("{$type}_id", $id)
|
||||
->first();
|
||||
if ($found) {
|
||||
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,27 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
||||
class GithubApp extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'private_key_id',
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'is_system_wide',
|
||||
'is_public',
|
||||
'contents',
|
||||
'metadata',
|
||||
'pull_requests',
|
||||
'administration',
|
||||
];
|
||||
|
||||
protected $appends = ['type'];
|
||||
|
||||
@@ -92,7 +112,7 @@ class GithubApp extends BaseModel
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if ($this->getMorphClass() === \App\Models\GithubApp::class) {
|
||||
if ($this->getMorphClass() === GithubApp::class) {
|
||||
return 'github';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,24 @@ namespace App\Models;
|
||||
|
||||
class GitlabApp extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_port',
|
||||
'custom_user',
|
||||
'is_system_wide',
|
||||
'is_public',
|
||||
'app_id',
|
||||
'app_secret',
|
||||
'oauth_id',
|
||||
'group_name',
|
||||
'public_key',
|
||||
'webhook_token',
|
||||
'deploy_key_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'webhook_token',
|
||||
'app_secret',
|
||||
|
||||
@@ -9,7 +9,43 @@ use Spatie\Url\Url;
|
||||
|
||||
class InstanceSettings extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'public_ipv4',
|
||||
'public_ipv6',
|
||||
'fqdn',
|
||||
'public_port_min',
|
||||
'public_port_max',
|
||||
'do_not_track',
|
||||
'is_auto_update_enabled',
|
||||
'is_registration_enabled',
|
||||
'next_channel',
|
||||
'smtp_enabled',
|
||||
'smtp_from_address',
|
||||
'smtp_from_name',
|
||||
'smtp_recipients',
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_encryption',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'smtp_timeout',
|
||||
'resend_enabled',
|
||||
'resend_api_key',
|
||||
'is_dns_validation_enabled',
|
||||
'custom_dns_servers',
|
||||
'instance_name',
|
||||
'is_api_enabled',
|
||||
'allowed_ips',
|
||||
'auto_update_frequency',
|
||||
'update_check_frequency',
|
||||
'new_version_available',
|
||||
'instance_timezone',
|
||||
'helper_version',
|
||||
'disable_two_step_confirmation',
|
||||
'is_sponsorship_popup_enabled',
|
||||
'dev_helper_version',
|
||||
'is_wire_navigate_enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'smtp_enabled' => 'boolean',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\FileStorageChanged;
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
@@ -14,11 +15,23 @@ class LocalFileVolume extends BaseModel
|
||||
// 'mount_path' => 'encrypted',
|
||||
'content' => 'encrypted',
|
||||
'is_directory' => 'boolean',
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'fs_path',
|
||||
'mount_path',
|
||||
'content',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'is_directory',
|
||||
'chown',
|
||||
'chmod',
|
||||
'is_based_on_git',
|
||||
'is_preview_suffix_enabled',
|
||||
];
|
||||
|
||||
public $appends = ['is_binary'];
|
||||
|
||||
@@ -26,7 +39,7 @@ class LocalFileVolume extends BaseModel
|
||||
{
|
||||
static::created(function (LocalFileVolume $fileVolume) {
|
||||
$fileVolume->load(['service']);
|
||||
dispatch(new \App\Jobs\ServerStorageSaveJob($fileVolume));
|
||||
dispatch(new ServerStorageSaveJob($fileVolume));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,15 +141,22 @@ class LocalFileVolume extends BaseModel
|
||||
$server = $this->resource->destination->server;
|
||||
}
|
||||
$commands = collect([]);
|
||||
|
||||
// Validate fs_path early before any shell interpolation
|
||||
validateShellSafePath($this->fs_path, 'storage path');
|
||||
$escapedFsPath = escapeshellarg($this->fs_path);
|
||||
$escapedWorkdir = escapeshellarg($workdir);
|
||||
|
||||
if ($this->is_directory) {
|
||||
$commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true");
|
||||
$commands->push("mkdir -p $workdir > /dev/null 2>&1 || true");
|
||||
$commands->push("cd $workdir");
|
||||
$commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
|
||||
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
|
||||
$commands->push("cd {$escapedWorkdir}");
|
||||
}
|
||||
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
|
||||
$parent_dir = str($this->fs_path)->beforeLast('/');
|
||||
if ($parent_dir != '') {
|
||||
$commands->push("mkdir -p $parent_dir > /dev/null 2>&1 || true");
|
||||
$escapedParentDir = escapeshellarg($parent_dir);
|
||||
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
||||
}
|
||||
}
|
||||
$path = data_get_str($this, 'fs_path');
|
||||
@@ -146,7 +166,7 @@ class LocalFileVolume extends BaseModel
|
||||
$path = $workdir.$path;
|
||||
}
|
||||
|
||||
// Validate and escape path to prevent command injection
|
||||
// Validate and escape resolved path (may differ from fs_path if relative)
|
||||
validateShellSafePath($path, 'storage path');
|
||||
$escapedPath = escapeshellarg($path);
|
||||
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalPersistentVolume extends Model
|
||||
class LocalPersistentVolume extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'mount_path',
|
||||
'host_path',
|
||||
'container_id',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'is_preview_suffix_enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function resource()
|
||||
{
|
||||
|
||||
+55
-21
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -65,6 +66,20 @@ class PrivateKey extends BaseModel
|
||||
}
|
||||
});
|
||||
|
||||
static::saved(function ($key) {
|
||||
if ($key->wasChanged('private_key')) {
|
||||
try {
|
||||
$key->storeInFileSystem();
|
||||
refresh_server_connection($key);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to resync SSH key after update', [
|
||||
'key_uuid' => $key->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function ($key) {
|
||||
self::deleteFromStorage($key);
|
||||
});
|
||||
@@ -185,29 +200,54 @@ class PrivateKey extends BaseModel
|
||||
{
|
||||
$filename = "ssh_key@{$this->uuid}";
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$keyLocation = $this->getKeyLocation();
|
||||
$lockFile = $keyLocation.'.lock';
|
||||
|
||||
// Ensure the storage directory exists and is writable
|
||||
$this->ensureStorageDirectoryExists();
|
||||
|
||||
// Attempt to store the private key
|
||||
$success = $disk->put($filename, $this->private_key);
|
||||
|
||||
if (! $success) {
|
||||
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
|
||||
// Use file locking to prevent concurrent writes from corrupting the key
|
||||
$lockHandle = fopen($lockFile, 'c');
|
||||
if ($lockHandle === false) {
|
||||
throw new \Exception("Failed to open lock file for SSH key: {$lockFile}");
|
||||
}
|
||||
|
||||
// Verify the file was actually created and has content
|
||||
if (! $disk->exists($filename)) {
|
||||
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
|
||||
}
|
||||
try {
|
||||
if (! flock($lockHandle, LOCK_EX)) {
|
||||
throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}");
|
||||
}
|
||||
|
||||
$storedContent = $disk->get($filename);
|
||||
if (empty($storedContent) || $storedContent !== $this->private_key) {
|
||||
$disk->delete($filename); // Clean up the bad file
|
||||
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
|
||||
}
|
||||
// Attempt to store the private key
|
||||
$success = $disk->put($filename, $this->private_key);
|
||||
|
||||
return $this->getKeyLocation();
|
||||
if (! $success) {
|
||||
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}");
|
||||
}
|
||||
|
||||
// Verify the file was actually created and has content
|
||||
if (! $disk->exists($filename)) {
|
||||
throw new \Exception("SSH key file was not created: {$keyLocation}");
|
||||
}
|
||||
|
||||
$storedContent = $disk->get($filename);
|
||||
if (empty($storedContent) || $storedContent !== $this->private_key) {
|
||||
$disk->delete($filename); // Clean up the bad file
|
||||
throw new \Exception("SSH key file content verification failed: {$keyLocation}");
|
||||
}
|
||||
|
||||
// Ensure correct permissions for SSH (0600 required)
|
||||
if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) {
|
||||
Log::warning('Failed to set SSH key file permissions to 0600', [
|
||||
'key_uuid' => $this->uuid,
|
||||
'path' => $keyLocation,
|
||||
]);
|
||||
}
|
||||
|
||||
return $keyLocation;
|
||||
} finally {
|
||||
flock($lockHandle, LOCK_UN);
|
||||
fclose($lockHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public static function deleteFromStorage(self $privateKey)
|
||||
@@ -254,12 +294,6 @@ class PrivateKey extends BaseModel
|
||||
return DB::transaction(function () use ($data) {
|
||||
$this->update($data);
|
||||
|
||||
try {
|
||||
$this->storeInFileSystem();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ class Project extends BaseModel
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'team_id',
|
||||
'uuid',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get query builder for projects owned by current team.
|
||||
@@ -69,7 +74,7 @@ class Project extends BaseModel
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->hasMany(SharedEnvironmentVariable::class);
|
||||
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'project');
|
||||
}
|
||||
|
||||
public function environments()
|
||||
|
||||
@@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectSetting extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
|
||||
@@ -25,7 +25,8 @@ class PushoverNotificationSettings extends Model
|
||||
'backup_failure_pushover_notifications',
|
||||
'scheduled_task_success_pushover_notifications',
|
||||
'scheduled_task_failure_pushover_notifications',
|
||||
'docker_cleanup_pushover_notifications',
|
||||
'docker_cleanup_success_pushover_notifications',
|
||||
'docker_cleanup_failure_pushover_notifications',
|
||||
'server_disk_usage_pushover_notifications',
|
||||
'server_reachable_pushover_notifications',
|
||||
'server_unreachable_pushover_notifications',
|
||||
|
||||
@@ -12,7 +12,17 @@ class S3Storage extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'region',
|
||||
'key',
|
||||
'secret',
|
||||
'bucket',
|
||||
'endpoint',
|
||||
'is_usable',
|
||||
'unusable_email_sent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_usable' => 'boolean',
|
||||
@@ -40,6 +50,13 @@ class S3Storage extends BaseModel
|
||||
$storage->secret = trim($storage->secret);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleting(function (S3Storage $storage) {
|
||||
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam(array $select = ['*'])
|
||||
@@ -59,6 +76,11 @@ class S3Storage extends BaseModel
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function scheduledBackups()
|
||||
{
|
||||
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function awsUrl()
|
||||
{
|
||||
return "{$this->endpoint}/{$this->bucket}";
|
||||
|
||||
@@ -8,7 +8,35 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ScheduledDatabaseBackup extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'database_backup_retention_max_storage_locally' => 'float',
|
||||
'database_backup_retention_max_storage_s3' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'description',
|
||||
'enabled',
|
||||
'save_s3',
|
||||
'frequency',
|
||||
'database_backup_retention_amount_locally',
|
||||
'database_type',
|
||||
'database_id',
|
||||
's3_storage_id',
|
||||
'databases_to_backup',
|
||||
'dump_all',
|
||||
'database_backup_retention_days_locally',
|
||||
'database_backup_retention_max_storage_locally',
|
||||
'database_backup_retention_amount_s3',
|
||||
'database_backup_retention_days_s3',
|
||||
'database_backup_retention_max_storage_s3',
|
||||
'timeout',
|
||||
'disable_local_backup',
|
||||
];
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ScheduledDatabaseBackupExecution extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'scheduled_database_backup_id',
|
||||
'status',
|
||||
'message',
|
||||
'size',
|
||||
'filename',
|
||||
'database_name',
|
||||
'finished_at',
|
||||
'local_storage_deleted',
|
||||
's3_storage_deleted',
|
||||
's3_uploaded',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
||||
@@ -29,7 +29,18 @@ class ScheduledTask extends BaseModel
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'enabled',
|
||||
'name',
|
||||
'command',
|
||||
'frequency',
|
||||
'container',
|
||||
'timeout',
|
||||
'team_id',
|
||||
'application_id',
|
||||
'service_id',
|
||||
];
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
|
||||
@@ -22,7 +22,16 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class ScheduledTaskExecution extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'scheduled_task_id',
|
||||
'status',
|
||||
'message',
|
||||
'finished_at',
|
||||
'started_at',
|
||||
'retry_count',
|
||||
'duration',
|
||||
'error_details',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
||||
+121
-24
@@ -11,7 +11,9 @@ use App\Enums\ProxyTypes;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Livewire\Server\Proxy;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
@@ -25,12 +27,14 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Stringable;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Spatie\SchemalessAttributes\Casts\SchemalessAttributes;
|
||||
use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
|
||||
use Spatie\Url\Url;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -76,8 +80,8 @@ use Visus\Cuid2\Cuid2;
|
||||
* - Traefik image uses the 'latest' tag (no fixed version tracking)
|
||||
* - No Traefik version detected on the server
|
||||
*
|
||||
* @see \App\Jobs\CheckTraefikVersionForServerJob Where this data is populated
|
||||
* @see \App\Livewire\Server\Proxy Where this data is read and displayed
|
||||
* @see CheckTraefikVersionForServerJob Where this data is populated
|
||||
* @see Proxy Where this data is read and displayed
|
||||
*/
|
||||
#[OA\Schema(
|
||||
description: 'Server model',
|
||||
@@ -131,10 +135,10 @@ class Server extends BaseModel
|
||||
$payload['ip_previous'] = $server->getOriginal('ip');
|
||||
}
|
||||
}
|
||||
$server->forceFill($payload);
|
||||
$server->fill($payload);
|
||||
});
|
||||
static::saved(function ($server) {
|
||||
if ($server->privateKey?->isDirty()) {
|
||||
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
|
||||
refresh_server_connection($server->privateKey);
|
||||
}
|
||||
});
|
||||
@@ -144,19 +148,14 @@ class Server extends BaseModel
|
||||
]);
|
||||
if ($server->id === 0) {
|
||||
if ($server->isSwarm()) {
|
||||
SwarmDocker::create([
|
||||
(new SwarmDocker)->forceFill([
|
||||
'id' => 0,
|
||||
'name' => 'coolify',
|
||||
'network' => 'coolify-overlay',
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
])->save();
|
||||
} else {
|
||||
StandaloneDocker::create([
|
||||
'id' => 0,
|
||||
'name' => 'coolify',
|
||||
'network' => 'coolify',
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
(new StandaloneDocker)->forceFill($server->defaultStandaloneDockerAttributes(id: 0))->saveQuietly();
|
||||
}
|
||||
} else {
|
||||
if ($server->isSwarm()) {
|
||||
@@ -166,18 +165,32 @@ class Server extends BaseModel
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} else {
|
||||
$standaloneDocker = new StandaloneDocker([
|
||||
'name' => 'coolify',
|
||||
'uuid' => (string) new Cuid2,
|
||||
'network' => 'coolify',
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$standaloneDocker = new StandaloneDocker;
|
||||
$standaloneDocker->forceFill($server->defaultStandaloneDockerAttributes());
|
||||
$standaloneDocker->saveQuietly();
|
||||
}
|
||||
}
|
||||
if (! isset($server->proxy->redirect_enabled)) {
|
||||
$server->proxy->redirect_enabled = true;
|
||||
}
|
||||
|
||||
// Create predefined server shared variables
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'COOLIFY_SERVER_UUID',
|
||||
'value' => $server->uuid,
|
||||
'type' => 'server',
|
||||
'server_id' => $server->id,
|
||||
'team_id' => $server->team_id,
|
||||
'is_literal' => true,
|
||||
]);
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'COOLIFY_SERVER_NAME',
|
||||
'value' => $server->name,
|
||||
'type' => 'server',
|
||||
'server_id' => $server->id,
|
||||
'team_id' => $server->team_id,
|
||||
'is_literal' => true,
|
||||
]);
|
||||
});
|
||||
static::retrieved(function ($server) {
|
||||
if (! isset($server->proxy->redirect_enabled)) {
|
||||
@@ -231,6 +244,7 @@ class Server extends BaseModel
|
||||
protected $casts = [
|
||||
'proxy' => SchemalessAttributes::class,
|
||||
'traefik_outdated_info' => 'array',
|
||||
'server_metadata' => 'array',
|
||||
'logdrain_axiom_api_key' => 'encrypted',
|
||||
'logdrain_newrelic_license_key' => 'encrypted',
|
||||
'delete_unused_volumes' => 'boolean',
|
||||
@@ -258,12 +272,19 @@ class Server extends BaseModel
|
||||
'is_validating',
|
||||
'detected_traefik_version',
|
||||
'traefik_outdated_info',
|
||||
'server_metadata',
|
||||
'ip_previous',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
public function setValidationLogsAttribute($value): void
|
||||
{
|
||||
$this->attributes['validation_logs'] = $value !== null
|
||||
? Purify::config('validation_logs')->clean($value)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
return 'server';
|
||||
@@ -716,17 +737,17 @@ $schema://$host {
|
||||
|
||||
public function stopUnmanaged($id)
|
||||
{
|
||||
return instant_remote_process(["docker stop -t 0 $id"], $this);
|
||||
return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this);
|
||||
}
|
||||
|
||||
public function restartUnmanaged($id)
|
||||
{
|
||||
return instant_remote_process(["docker restart $id"], $this);
|
||||
return instant_remote_process(['docker restart '.escapeshellarg($id)], $this);
|
||||
}
|
||||
|
||||
public function startUnmanaged($id)
|
||||
{
|
||||
return instant_remote_process(["docker start $id"], $this);
|
||||
return instant_remote_process(['docker start '.escapeshellarg($id)], $this);
|
||||
}
|
||||
|
||||
public function getContainers()
|
||||
@@ -1012,6 +1033,30 @@ $schema://$host {
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id?: int, name: string, uuid: string, network: string, server_id: int}
|
||||
*/
|
||||
public function defaultStandaloneDockerAttributes(?int $id = null): array
|
||||
{
|
||||
$attributes = [
|
||||
'name' => 'coolify',
|
||||
'uuid' => (string) new Cuid2,
|
||||
'network' => 'coolify',
|
||||
'server_id' => $this->id,
|
||||
];
|
||||
|
||||
if (! is_null($id)) {
|
||||
$attributes['id'] = $id;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server');
|
||||
}
|
||||
|
||||
public function isProxyShouldRun()
|
||||
{
|
||||
// TODO: Do we need "|| $this->proxy->force_stop" here?
|
||||
@@ -1074,6 +1119,55 @@ $schema://$host {
|
||||
}
|
||||
}
|
||||
|
||||
public function gatherServerMetadata(): ?array
|
||||
{
|
||||
if (! $this->isFunctional()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$output = instant_remote_process([
|
||||
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s',
|
||||
], $this, false);
|
||||
|
||||
if (! $output) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sections = [];
|
||||
$currentKey = null;
|
||||
foreach (explode("\n", trim($output)) as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^---(\w+)---$/', $line, $m)) {
|
||||
$currentKey = $m[1];
|
||||
} elseif ($currentKey) {
|
||||
$sections[$currentKey] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'os' => $sections['PRETTY_NAME'] ?? 'Unknown',
|
||||
'arch' => $sections['ARCH'] ?? 'Unknown',
|
||||
'kernel' => $sections['KERNEL'] ?? 'Unknown',
|
||||
'cpus' => (int) ($sections['CPUS'] ?? 0),
|
||||
'memory_bytes' => (int) ($sections['MEMORY'] ?? 0),
|
||||
'uptime_since' => $sections['UPTIME_SINCE'] ?? null,
|
||||
'collected_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$this->update(['server_metadata' => $metadata]);
|
||||
|
||||
return $metadata;
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('Failed to gather server metadata', [
|
||||
'server_id' => $this->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function isTerminalEnabled()
|
||||
{
|
||||
return $this->settings->is_terminal_enabled ?? false;
|
||||
@@ -1408,7 +1502,7 @@ $schema://$host {
|
||||
|
||||
public function restartContainer(string $containerName)
|
||||
{
|
||||
return instant_remote_process(['docker restart '.$containerName], $this, false);
|
||||
return instant_remote_process(['docker restart '.escapeshellarg($containerName)], $this, false);
|
||||
}
|
||||
|
||||
public function changeProxy(string $proxyType, bool $async = true)
|
||||
@@ -1419,6 +1513,9 @@ $schema://$host {
|
||||
if ($validProxyTypes->contains(str($proxyType)->lower())) {
|
||||
$this->proxy->set('type', str($proxyType)->upper());
|
||||
$this->proxy->set('status', 'exited');
|
||||
$this->proxy->set('last_saved_proxy_configuration', null);
|
||||
$this->proxy->set('last_saved_settings', null);
|
||||
$this->proxy->set('last_applied_settings', null);
|
||||
$this->save();
|
||||
if ($this->proxySet()) {
|
||||
if ($async) {
|
||||
|
||||
@@ -53,9 +53,54 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class ServerSetting extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'is_swarm_manager',
|
||||
'is_jump_server',
|
||||
'is_build_server',
|
||||
'is_reachable',
|
||||
'is_usable',
|
||||
'wildcard_domain',
|
||||
'is_cloudflare_tunnel',
|
||||
'is_logdrain_newrelic_enabled',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_newrelic_base_uri',
|
||||
'is_logdrain_highlight_enabled',
|
||||
'logdrain_highlight_project_id',
|
||||
'is_logdrain_axiom_enabled',
|
||||
'logdrain_axiom_dataset_name',
|
||||
'logdrain_axiom_api_key',
|
||||
'is_swarm_worker',
|
||||
'is_logdrain_custom_enabled',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
'concurrent_builds',
|
||||
'dynamic_timeout',
|
||||
'force_disabled',
|
||||
'is_metrics_enabled',
|
||||
'generate_exact_labels',
|
||||
'force_docker_cleanup',
|
||||
'docker_cleanup_frequency',
|
||||
'docker_cleanup_threshold',
|
||||
'server_timezone',
|
||||
'delete_unused_volumes',
|
||||
'delete_unused_networks',
|
||||
'is_sentinel_enabled',
|
||||
'sentinel_token',
|
||||
'sentinel_metrics_refresh_rate_seconds',
|
||||
'sentinel_metrics_history_days',
|
||||
'sentinel_push_interval_seconds',
|
||||
'sentinel_custom_url',
|
||||
'server_disk_usage_notification_threshold',
|
||||
'is_sentinel_debug_enabled',
|
||||
'server_disk_usage_check_frequency',
|
||||
'is_terminal_enabled',
|
||||
'deployment_queue_limit',
|
||||
'disable_application_image_retention',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'force_disabled' => 'boolean',
|
||||
'force_docker_cleanup' => 'boolean',
|
||||
'docker_cleanup_threshold' => 'integer',
|
||||
'sentinel_token' => 'encrypted',
|
||||
@@ -92,6 +137,15 @@ class ServerSetting extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a sentinel token contains only safe characters.
|
||||
* Prevents OS command injection when the token is interpolated into shell commands.
|
||||
*/
|
||||
public static function isValidSentinelToken(string $token): bool
|
||||
{
|
||||
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
||||
}
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
|
||||
{
|
||||
$data = [
|
||||
|
||||
+18
-2
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
#[OA\Schema(
|
||||
@@ -47,7 +48,22 @@ class Service extends BaseModel
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'docker_compose_raw',
|
||||
'docker_compose',
|
||||
'connect_to_docker_network',
|
||||
'service_type',
|
||||
'config_hash',
|
||||
'compose_parsing_version',
|
||||
'is_container_label_escape_enabled',
|
||||
'environment_id',
|
||||
'server_id',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
];
|
||||
|
||||
protected $appends = ['server_status', 'status'];
|
||||
|
||||
@@ -1552,7 +1568,7 @@ class Service extends BaseModel
|
||||
// Generate SERVICE_NAME_* environment variables from docker-compose services
|
||||
if ($this->docker_compose) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose);
|
||||
$dockerCompose = Yaml::parse($this->docker_compose);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
|
||||
|
||||
@@ -5,12 +5,31 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServiceApplication extends BaseModel
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'name',
|
||||
'human_name',
|
||||
'description',
|
||||
'fqdn',
|
||||
'ports',
|
||||
'exposes',
|
||||
'status',
|
||||
'exclude_from_status',
|
||||
'required_fqdn',
|
||||
'image',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'is_gzip_enabled',
|
||||
'is_stripprefix_enabled',
|
||||
'last_online_at',
|
||||
'is_migrated',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
@@ -21,7 +40,7 @@ class ServiceApplication extends BaseModel
|
||||
});
|
||||
static::saving(function ($service) {
|
||||
if ($service->isDirty('status')) {
|
||||
$service->forceFill(['last_online_at' => now()]);
|
||||
$service->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -211,7 +230,7 @@ class ServiceApplication extends BaseModel
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$dockerCompose = Yaml::parse($dockerComposeRaw);
|
||||
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
|
||||
if (! $serviceConfig) {
|
||||
return $this->service->getRequiredPort();
|
||||
|
||||
@@ -9,7 +9,28 @@ class ServiceDatabase extends BaseModel
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'name',
|
||||
'human_name',
|
||||
'description',
|
||||
'fqdn',
|
||||
'ports',
|
||||
'exposes',
|
||||
'status',
|
||||
'exclude_from_status',
|
||||
'image',
|
||||
'public_port',
|
||||
'is_public',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'is_gzip_enabled',
|
||||
'is_stripprefix_enabled',
|
||||
'last_online_at',
|
||||
'is_migrated',
|
||||
'custom_type',
|
||||
'public_port_timeout',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'public_port_timeout' => 'integer',
|
||||
@@ -24,7 +45,7 @@ class ServiceDatabase extends BaseModel
|
||||
});
|
||||
static::saving(function ($service) {
|
||||
if ($service->isDirty('status')) {
|
||||
$service->forceFill(['last_online_at' => now()]);
|
||||
$service->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,11 +17,15 @@ class SharedEnvironmentVariable extends Model
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'server_id',
|
||||
|
||||
// Boolean flags
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_shown_once',
|
||||
|
||||
// Metadata
|
||||
'version',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -43,4 +47,9 @@ class SharedEnvironmentVariable extends Model
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function server()
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ class SlackNotificationSettings extends Model
|
||||
'backup_failure_slack_notifications',
|
||||
'scheduled_task_success_slack_notifications',
|
||||
'scheduled_task_failure_slack_notifications',
|
||||
'docker_cleanup_slack_notifications',
|
||||
'docker_cleanup_success_slack_notifications',
|
||||
'docker_cleanup_failure_slack_notifications',
|
||||
'server_disk_usage_slack_notifications',
|
||||
'server_reachable_slack_notifications',
|
||||
'server_unreachable_slack_notifications',
|
||||
|
||||
@@ -13,12 +13,43 @@ class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'clickhouse_admin_user',
|
||||
'clickhouse_admin_password',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'custom_docker_run_options',
|
||||
'clickhouse_db',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'clickhouse_password' => 'encrypted',
|
||||
'clickhouse_admin_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
@@ -44,7 +75,7 @@ class StandaloneClickhouse extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,7 +166,7 @@ class StandaloneClickhouse extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\ConnectProxyToNetworksJob;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
@@ -11,20 +12,34 @@ class StandaloneDocker extends BaseModel
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'name',
|
||||
'network',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::created(function ($newStandaloneDocker) {
|
||||
$server = $newStandaloneDocker->server;
|
||||
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
|
||||
instant_remote_process([
|
||||
"docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null",
|
||||
"docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null",
|
||||
], $server, false);
|
||||
ConnectProxyToNetworksJob::dispatchSync($server);
|
||||
});
|
||||
}
|
||||
|
||||
public function setNetworkAttribute(string $value): void
|
||||
{
|
||||
if (! ValidationPatterns::isValidDockerNetwork($value)) {
|
||||
throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.');
|
||||
}
|
||||
|
||||
$this->attributes['network'] = $value;
|
||||
}
|
||||
|
||||
public function applications()
|
||||
{
|
||||
return $this->morphMany(Application::class, 'destination');
|
||||
@@ -75,6 +90,16 @@ class StandaloneDocker extends BaseModel
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server attribute using identity map caching.
|
||||
* This intercepts lazy-loading to use cached Server lookups.
|
||||
|
||||
@@ -13,7 +13,37 @@ class StandaloneDragonfly extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'dragonfly_password',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
@@ -44,7 +74,7 @@ class StandaloneDragonfly extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,7 +165,7 @@ class StandaloneDragonfly extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,38 @@ class StandaloneKeydb extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'keydb_password',
|
||||
'keydb_conf',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
|
||||
|
||||
@@ -44,7 +75,7 @@ class StandaloneKeydb extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,7 +166,7 @@ class StandaloneKeydb extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,40 @@ class StandaloneMariadb extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'mariadb_root_password',
|
||||
'mariadb_user',
|
||||
'mariadb_password',
|
||||
'mariadb_database',
|
||||
'mariadb_conf',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'is_log_drain_enabled',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
@@ -45,7 +78,7 @@ class StandaloneMariadb extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -136,7 +169,7 @@ class StandaloneMariadb extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,41 @@ class StandaloneMongodb extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'mongo_conf',
|
||||
'mongo_initdb_root_username',
|
||||
'mongo_initdb_root_password',
|
||||
'mongo_initdb_database',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'ssl_mode',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
@@ -50,7 +84,7 @@ class StandaloneMongodb extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -141,7 +175,7 @@ class StandaloneMongodb extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,42 @@ class StandaloneMysql extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'mysql_root_password',
|
||||
'mysql_user',
|
||||
'mysql_password',
|
||||
'mysql_database',
|
||||
'mysql_conf',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'ssl_mode',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
@@ -45,7 +80,7 @@ class StandaloneMysql extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -136,7 +171,7 @@ class StandaloneMysql extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,44 @@ class StandalonePostgresql extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'postgres_user',
|
||||
'postgres_password',
|
||||
'postgres_db',
|
||||
'postgres_initdb_args',
|
||||
'postgres_host_auth_method',
|
||||
'postgres_conf',
|
||||
'init_scripts',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'ssl_mode',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
@@ -59,7 +96,7 @@ class StandalonePostgresql extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -114,7 +151,7 @@ class StandalonePostgresql extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,37 @@ class StandaloneRedis extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'redis_conf',
|
||||
'status',
|
||||
'image',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'ports_mappings',
|
||||
'limits_memory',
|
||||
'limits_memory_swap',
|
||||
'limits_memory_swappiness',
|
||||
'limits_memory_reservation',
|
||||
'limits_cpus',
|
||||
'limits_cpuset',
|
||||
'limits_cpu_shares',
|
||||
'started_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'is_log_drain_enabled',
|
||||
'is_include_timestamps',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
@@ -43,7 +73,7 @@ class StandaloneRedis extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -140,7 +170,7 @@ class StandaloneRedis extends BaseModel
|
||||
}
|
||||
$server = data_get($this, 'destination.server');
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,19 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'stripe_invoice_paid',
|
||||
'stripe_subscription_id',
|
||||
'stripe_customer_id',
|
||||
'stripe_cancel_at_period_end',
|
||||
'stripe_plan_id',
|
||||
'stripe_feedback',
|
||||
'stripe_comment',
|
||||
'stripe_trial_already_ended',
|
||||
'stripe_past_due',
|
||||
'stripe_refunded_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
||||
@@ -2,9 +2,24 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
class SwarmDocker extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'name',
|
||||
'network',
|
||||
];
|
||||
|
||||
public function setNetworkAttribute(string $value): void
|
||||
{
|
||||
if (! ValidationPatterns::isValidDockerNetwork($value)) {
|
||||
throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.');
|
||||
}
|
||||
|
||||
$this->attributes['network'] = $value;
|
||||
}
|
||||
|
||||
public function applications()
|
||||
{
|
||||
@@ -56,6 +71,16 @@ class SwarmDocker extends BaseModel
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server attribute using identity map caching.
|
||||
* This intercepts lazy-loading to use cached Server lookups.
|
||||
|
||||
+4
-1
@@ -8,7 +8,10 @@ class Tag extends BaseModel
|
||||
{
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
protected function customizeName($value)
|
||||
{
|
||||
|
||||
+47
-21
@@ -40,7 +40,13 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
{
|
||||
use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'personal_team',
|
||||
'show_boarding',
|
||||
'custom_server_limit',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'personal_team' => 'boolean',
|
||||
@@ -65,34 +71,43 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
}
|
||||
});
|
||||
|
||||
static::deleting(function ($team) {
|
||||
$keys = $team->privateKeys;
|
||||
foreach ($keys as $key) {
|
||||
static::deleting(function (Team $team) {
|
||||
foreach ($team->privateKeys as $key) {
|
||||
$key->delete();
|
||||
}
|
||||
$sources = $team->sources();
|
||||
foreach ($sources as $source) {
|
||||
|
||||
// Transfer instance-wide sources to root team so they remain available
|
||||
GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
|
||||
GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
|
||||
|
||||
// Delete non-instance-wide sources owned by this team
|
||||
$teamSources = GithubApp::where('team_id', $team->id)->get()
|
||||
->merge(GitlabApp::where('team_id', $team->id)->get());
|
||||
foreach ($teamSources as $source) {
|
||||
$source->delete();
|
||||
}
|
||||
$tags = Tag::whereTeamId($team->id)->get();
|
||||
foreach ($tags as $tag) {
|
||||
|
||||
foreach (Tag::whereTeamId($team->id)->get() as $tag) {
|
||||
$tag->delete();
|
||||
}
|
||||
$shared_variables = $team->environment_variables();
|
||||
foreach ($shared_variables as $shared_variable) {
|
||||
$shared_variable->delete();
|
||||
|
||||
foreach ($team->environment_variables()->get() as $sharedVariable) {
|
||||
$sharedVariable->delete();
|
||||
}
|
||||
$s3s = $team->s3s;
|
||||
foreach ($s3s as $s3) {
|
||||
|
||||
foreach ($team->s3s as $s3) {
|
||||
$s3->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function serverLimitReached()
|
||||
public static function serverLimitReached(?Team $team = null)
|
||||
{
|
||||
$serverLimit = Team::serverLimit();
|
||||
$team = currentTeam();
|
||||
$team = $team ?? currentTeam();
|
||||
if (! $team) {
|
||||
return true;
|
||||
}
|
||||
$serverLimit = Team::serverLimit($team);
|
||||
$servers = $team->servers->count();
|
||||
|
||||
return $servers >= $serverLimit;
|
||||
@@ -109,19 +124,23 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
|
||||
public function serverOverflow()
|
||||
{
|
||||
if ($this->serverLimit() < $this->servers->count()) {
|
||||
if (Team::serverLimit($this) < $this->servers->count()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function serverLimit()
|
||||
public static function serverLimit(?Team $team = null)
|
||||
{
|
||||
if (currentTeam()->id === 0 && isDev()) {
|
||||
$team = $team ?? currentTeam();
|
||||
if (! $team) {
|
||||
return 0;
|
||||
}
|
||||
if ($team->id === 0 && isDev()) {
|
||||
return 9999999;
|
||||
}
|
||||
$team = Team::find(currentTeam()->id);
|
||||
$team = Team::find($team->id);
|
||||
if (! $team) {
|
||||
return 0;
|
||||
}
|
||||
@@ -197,6 +216,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
|
||||
public function subscriptionEnded()
|
||||
{
|
||||
if (! $this->subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->subscription->update([
|
||||
'stripe_subscription_id' => null,
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
@@ -210,12 +233,15 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
ServerReachabilityChanged::dispatch($server);
|
||||
$server->unreachable_count = 3;
|
||||
$server->unreachable_notification_sent = true;
|
||||
$server->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->hasMany(SharedEnvironmentVariable::class)->whereNull('project_id')->whereNull('environment_id');
|
||||
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'team');
|
||||
}
|
||||
|
||||
public function members()
|
||||
|
||||
@@ -25,7 +25,8 @@ class TelegramNotificationSettings extends Model
|
||||
'backup_failure_telegram_notifications',
|
||||
'scheduled_task_success_telegram_notifications',
|
||||
'scheduled_task_failure_telegram_notifications',
|
||||
'docker_cleanup_telegram_notifications',
|
||||
'docker_cleanup_success_telegram_notifications',
|
||||
'docker_cleanup_failure_telegram_notifications',
|
||||
'server_disk_usage_telegram_notifications',
|
||||
'server_reachable_telegram_notifications',
|
||||
'server_unreachable_telegram_notifications',
|
||||
@@ -39,7 +40,8 @@ class TelegramNotificationSettings extends Model
|
||||
'telegram_notifications_backup_failure_thread_id',
|
||||
'telegram_notifications_scheduled_task_success_thread_id',
|
||||
'telegram_notifications_scheduled_task_failure_thread_id',
|
||||
'telegram_notifications_docker_cleanup_thread_id',
|
||||
'telegram_notifications_docker_cleanup_success_thread_id',
|
||||
'telegram_notifications_docker_cleanup_failure_thread_id',
|
||||
'telegram_notifications_server_disk_usage_thread_id',
|
||||
'telegram_notifications_server_reachable_thread_id',
|
||||
'telegram_notifications_server_unreachable_thread_id',
|
||||
|
||||
+22
-9
@@ -4,7 +4,9 @@ namespace App\Models;
|
||||
|
||||
use App\Jobs\UpdateStripeCustomerEmailJob;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
use App\Notifications\TransactionalEmails\EmailChangeVerification;
|
||||
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
|
||||
use App\Services\ChangelogService;
|
||||
use App\Traits\DeletesUserSessions;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -41,7 +43,16 @@ class User extends Authenticatable implements SendsEmail
|
||||
{
|
||||
use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'force_password_reset',
|
||||
'marketing_emails',
|
||||
'pending_email',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
@@ -87,7 +98,8 @@ class User extends Authenticatable implements SendsEmail
|
||||
$team['id'] = 0;
|
||||
$team['name'] = 'Root Team';
|
||||
}
|
||||
$new_team = Team::create($team);
|
||||
$new_team = (new Team)->forceFill($team);
|
||||
$new_team->save();
|
||||
$user->teams()->attach($new_team, ['role' => 'owner']);
|
||||
});
|
||||
|
||||
@@ -190,7 +202,8 @@ class User extends Authenticatable implements SendsEmail
|
||||
$team['id'] = 0;
|
||||
$team['name'] = 'Root Team';
|
||||
}
|
||||
$new_team = Team::create($team);
|
||||
$new_team = (new Team)->forceFill($team);
|
||||
$new_team->save();
|
||||
$this->teams()->attach($new_team, ['role' => 'owner']);
|
||||
|
||||
return $new_team;
|
||||
@@ -228,7 +241,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
|
||||
public function getUnreadChangelogCount(): int
|
||||
{
|
||||
return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this);
|
||||
return app(ChangelogService::class)->getUnreadCountForUser($this);
|
||||
}
|
||||
|
||||
public function getRecipients(): array
|
||||
@@ -239,7 +252,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
public function sendVerificationEmail()
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$url = Url::temporarySignedRoute(
|
||||
$url = URL::temporarySignedRoute(
|
||||
'verify.verify',
|
||||
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
|
||||
[
|
||||
@@ -400,20 +413,20 @@ class User extends Authenticatable implements SendsEmail
|
||||
public function requestEmailChange(string $newEmail): void
|
||||
{
|
||||
// Generate 6-digit code
|
||||
$code = sprintf('%06d', mt_rand(0, 999999));
|
||||
$code = sprintf('%06d', random_int(0, 999999));
|
||||
|
||||
// Set expiration using config value
|
||||
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
|
||||
$expiresAt = Carbon::now()->addMinutes($expiryMinutes);
|
||||
|
||||
$this->update([
|
||||
$this->fill([
|
||||
'pending_email' => $newEmail,
|
||||
'email_change_code' => $code,
|
||||
'email_change_code_expires_at' => $expiresAt,
|
||||
]);
|
||||
])->save();
|
||||
|
||||
// Send verification email to new address
|
||||
$this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt));
|
||||
$this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt));
|
||||
}
|
||||
|
||||
public function isEmailChangeCodeValid(string $code): bool
|
||||
|
||||
Reference in new issue
Block a user