mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 00:16:42 +00:00
Merge remote-tracking branch 'origin/next' into feat/api/tag-management
# Conflicts: # app/Http/Controllers/Api/ApplicationsController.php
This commit is contained in:
commit
f617e58401
1196 files changed
+69504
-18702
No files matched your search
+338
-179
@@ -4,6 +4,9 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Services\ConfigurationGenerator;
|
||||
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiff;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
@@ -20,7 +23,6 @@ use RuntimeException;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Application model',
|
||||
@@ -39,7 +41,7 @@ use Visus\Cuid2\Cuid2;
|
||||
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
|
||||
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
|
||||
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']],
|
||||
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']],
|
||||
'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'],
|
||||
'install_command' => ['type' => 'string', 'description' => 'Install command.'],
|
||||
'build_command' => ['type' => 'string', 'description' => 'Build command.'],
|
||||
@@ -201,20 +203,43 @@ class Application extends BaseModel
|
||||
'config_hash',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'max_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',
|
||||
'max_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',
|
||||
@@ -262,7 +287,7 @@ class Application extends BaseModel
|
||||
}
|
||||
}
|
||||
if (count($payload) > 0) {
|
||||
$application->forceFill($payload);
|
||||
$application->fill($payload);
|
||||
}
|
||||
|
||||
// Buildpack switching cleanup logic
|
||||
@@ -546,6 +571,15 @@ class Application extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
public function stoppedAfterRestartLimit(): bool
|
||||
{
|
||||
return str($this->status)->startsWith('exited')
|
||||
&& ($this->restart_count ?? 0) > 0
|
||||
&& ($this->max_restart_count ?? 0) > 0
|
||||
&& $this->restart_count >= $this->max_restart_count
|
||||
&& $this->last_restart_type === 'crash';
|
||||
}
|
||||
|
||||
public function taskLink($task_uuid)
|
||||
{
|
||||
if (data_get($this, 'environment.project.uuid')) {
|
||||
@@ -699,14 +733,14 @@ class Application extends BaseModel
|
||||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return '/Dockerfile';
|
||||
} else {
|
||||
if ($value !== '/') {
|
||||
return Str::start(Str::replaceEnd('/', '', $value), '/');
|
||||
}
|
||||
|
||||
return Str::start($value, '/');
|
||||
return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null;
|
||||
}
|
||||
|
||||
if ($value !== '/') {
|
||||
return Str::start(Str::replaceEnd('/', '', $value), '/');
|
||||
}
|
||||
|
||||
return Str::start($value, '/');
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -865,8 +899,8 @@ class Application extends BaseModel
|
||||
public function customNginxConfiguration(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: fn ($value) => base64_encode($value),
|
||||
get: fn ($value) => base64_decode($value),
|
||||
set: fn ($value) => is_null($value) ? null : base64_encode($value),
|
||||
get: fn ($value) => is_null($value) ? null : base64_decode($value),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -939,7 +973,7 @@ class Application extends BaseModel
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
->withoutBuildpackControlVariables();
|
||||
}
|
||||
|
||||
public function nixpacks_environment_variables()
|
||||
@@ -949,6 +983,13 @@ class Application extends BaseModel
|
||||
->where('key', 'like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function railpack_environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->where('key', 'like', 'RAILPACK_%');
|
||||
}
|
||||
|
||||
public function environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
@@ -967,7 +1008,7 @@ class Application extends BaseModel
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
->withoutBuildpackControlVariables();
|
||||
}
|
||||
|
||||
public function nixpacks_environment_variables_preview()
|
||||
@@ -977,6 +1018,13 @@ class Application extends BaseModel
|
||||
->where('key', 'like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function railpack_environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->where('key', 'like', 'RAILPACK_%');
|
||||
}
|
||||
|
||||
public function scheduled_tasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
|
||||
@@ -1024,7 +1072,7 @@ class Application extends BaseModel
|
||||
|
||||
public function get_last_successful_deployment()
|
||||
{
|
||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
|
||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
|
||||
}
|
||||
|
||||
public function get_last_days_deployments()
|
||||
@@ -1096,7 +1144,7 @@ class Application extends BaseModel
|
||||
|
||||
public function could_set_build_commands(): bool
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1136,32 +1184,94 @@ 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);
|
||||
$configurationDiff = $this->pendingDeploymentConfigurationDiff();
|
||||
|
||||
if ($save) {
|
||||
$this->markDeploymentConfigurationApplied();
|
||||
}
|
||||
|
||||
return $configurationDiff->isChanged();
|
||||
}
|
||||
|
||||
public function pendingDeploymentConfigurationDiff(): ConfigurationDiff
|
||||
{
|
||||
$currentSnapshot = $this->deploymentConfigurationSnapshot();
|
||||
$lastDeployment = $this->get_last_successful_deployment();
|
||||
|
||||
$previousSnapshot = $lastDeployment?->configuration_snapshot;
|
||||
|
||||
if (! $previousSnapshot) {
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
$hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash();
|
||||
|
||||
if (! $hasLegacyChange) {
|
||||
return ConfigurationDiff::unchanged();
|
||||
}
|
||||
|
||||
$previousSnapshot = [];
|
||||
}
|
||||
|
||||
return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot);
|
||||
}
|
||||
|
||||
public function hasPendingDeploymentConfigurationChanges(): bool
|
||||
{
|
||||
return $this->pendingDeploymentConfigurationDiff()->isChanged();
|
||||
}
|
||||
|
||||
public function deploymentConfigurationSnapshot(): array
|
||||
{
|
||||
return (new ApplicationConfigurationSnapshot($this))->toArray();
|
||||
}
|
||||
|
||||
public function deploymentConfigurationHash(): string
|
||||
{
|
||||
return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot());
|
||||
}
|
||||
|
||||
public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void
|
||||
{
|
||||
$this->refresh();
|
||||
|
||||
if (! $deployment) {
|
||||
$this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->deploymentConfigurationSnapshot();
|
||||
$hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot);
|
||||
|
||||
$previousDeployment = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', $this->id)
|
||||
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
||||
->where('pull_request_id', $deployment->pull_request_id ?? 0)
|
||||
->where('id', '!=', $deployment->id)
|
||||
->whereNotNull('configuration_snapshot')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$deployment->update([
|
||||
'configuration_hash' => $hash,
|
||||
'configuration_snapshot' => $snapshot,
|
||||
'configuration_diff' => $previousDeployment?->configuration_snapshot
|
||||
? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray()
|
||||
: null,
|
||||
]);
|
||||
|
||||
$this->forceFill(['config_hash' => $hash])->save();
|
||||
}
|
||||
|
||||
private function legacyConfigurationHash(): string
|
||||
{
|
||||
$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 {
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
}
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
if ($oldConfigHash === null) {
|
||||
if ($save) {
|
||||
$this->config_hash = $newConfigHash;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if ($oldConfigHash === $newConfigHash) {
|
||||
return false;
|
||||
} else {
|
||||
if ($save) {
|
||||
$this->config_hash = $newConfigHash;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return md5($newConfigHash);
|
||||
}
|
||||
|
||||
public function customRepository()
|
||||
@@ -1179,15 +1289,19 @@ class Application extends BaseModel
|
||||
return application_configuration_dir()."/{$this->uuid}";
|
||||
}
|
||||
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null)
|
||||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
|
||||
|
||||
// Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided,
|
||||
// so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone.
|
||||
$sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
|
||||
$resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand;
|
||||
$sshCommand = $resolvedGitSshCommand
|
||||
? (str_starts_with($resolvedGitSshCommand, 'GIT_SSH_COMMAND=')
|
||||
? $resolvedGitSshCommand
|
||||
: 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"')
|
||||
: 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
|
||||
|
||||
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
|
||||
// Invalid refs will cause the git checkout/fetch command to fail on the remote server.
|
||||
@@ -1198,9 +1312,9 @@ class Application extends BaseModel
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
@@ -1211,10 +1325,10 @@ class Application extends BaseModel
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
$git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
}
|
||||
if ($this->settings->is_git_lfs_enabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull";
|
||||
}
|
||||
|
||||
return $git_clone_command;
|
||||
@@ -1224,7 +1338,7 @@ class Application extends BaseModel
|
||||
{
|
||||
try {
|
||||
['commands' => $lsRemoteCommand] = $this->generateGitLsRemoteCommands(deployment_uuid: $deployment_uuid, exec_in_docker: false);
|
||||
instant_remote_process([$lsRemoteCommand], $this->destination->server, true);
|
||||
instant_remote_process([$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true);
|
||||
|
||||
return [
|
||||
'is_accessible' => true,
|
||||
@@ -1243,6 +1357,7 @@ class Application extends BaseModel
|
||||
$branch = $this->git_branch;
|
||||
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
|
||||
$commands = collect([]);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}";
|
||||
$base_command = 'git ls-remote';
|
||||
|
||||
if ($this->deploymentType() === 'source') {
|
||||
@@ -1275,13 +1390,13 @@ class Application extends BaseModel
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1296,30 +1411,18 @@ class Application extends BaseModel
|
||||
$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}'";
|
||||
$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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
|
||||
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'),
|
||||
]);
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} 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);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1331,13 +1434,13 @@ class Application extends BaseModel
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1354,30 +1457,18 @@ class Application extends BaseModel
|
||||
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
|
||||
// Replace ' with '\'' to safely escape within single-quoted bash strings
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "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\" {$base_command} '{$escapedCustomRepository}'";
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
|
||||
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'),
|
||||
]);
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} 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);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1389,24 +1480,88 @@ class Application extends BaseModel
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function gitCommand(string $command): array
|
||||
{
|
||||
return [
|
||||
'command' => $command,
|
||||
'hidden' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function gitCommandsAsShellCommand(Collection|array|string $commands): string
|
||||
{
|
||||
if (is_string($commands)) {
|
||||
return $commands;
|
||||
}
|
||||
|
||||
return collect($commands)
|
||||
->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command)
|
||||
->implode(' && ');
|
||||
}
|
||||
|
||||
private function gitSshKeySetupCommands(string $deploymentUuid, string $privateKey, bool $execInDocker): Collection
|
||||
{
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deploymentUuid}";
|
||||
$commands = collect([]);
|
||||
|
||||
if (! $execInDocker) {
|
||||
$commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation}' EXIT"));
|
||||
}
|
||||
|
||||
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, 'mkdir -p /root/.ssh') : 'mkdir -p /root/.ssh'));
|
||||
$commands->push([
|
||||
'command' => $execInDocker
|
||||
? executeInDocker($deploymentUuid, "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null")
|
||||
: "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
'hidden' => true,
|
||||
'skip_command_log' => true,
|
||||
]);
|
||||
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, "chmod 600 {$customSshKeyLocation}") : "chmod 600 {$customSshKeyLocation}"));
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string
|
||||
{
|
||||
return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1');
|
||||
}
|
||||
|
||||
private function isHttpGitRepository(string $repository): bool
|
||||
{
|
||||
return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://');
|
||||
}
|
||||
|
||||
private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string
|
||||
{
|
||||
$configuredCommand = preg_replace(
|
||||
"/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/",
|
||||
"git {$gitConfigOptions} clone",
|
||||
$gitCloneCommand,
|
||||
1
|
||||
);
|
||||
|
||||
return $configuredCommand ?: $gitCloneCommand;
|
||||
}
|
||||
|
||||
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
|
||||
{
|
||||
$branch = $this->git_branch;
|
||||
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
|
||||
$baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}";
|
||||
|
||||
// Escape shell arguments for safety to prevent command injection
|
||||
$escapedBranch = escapeshellarg($branch);
|
||||
@@ -1444,17 +1599,25 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
$gitConfigOptions = $this->withGitHttpTransportConfig();
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
$encodedToken = rawurlencode($github_access_token);
|
||||
|
||||
// Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials.
|
||||
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/");
|
||||
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
|
||||
$git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
@@ -1466,29 +1629,31 @@ class Application extends BaseModel
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
}
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
}
|
||||
if ($pull_request_id !== 0) {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions ?? null);
|
||||
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
|
||||
$escapedPrBranch = escapeshellarg($branch);
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")));
|
||||
} else {
|
||||
$commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command");
|
||||
$commands->push($this->gitCommand("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1503,45 +1668,34 @@ class Application extends BaseModel
|
||||
$private_key = base64_encode($private_key);
|
||||
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
|
||||
$git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
|
||||
$gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\"";
|
||||
$git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand);
|
||||
}
|
||||
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',
|
||||
]);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
|
||||
}
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
|
||||
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'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1551,16 +1705,20 @@ class Application extends BaseModel
|
||||
$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);
|
||||
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
|
||||
if ($gitConfigOptions) {
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
}
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1574,61 +1732,50 @@ class Application extends BaseModel
|
||||
}
|
||||
$private_key = base64_encode($private_key);
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
|
||||
$git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
|
||||
$deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\"";
|
||||
$git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand);
|
||||
}
|
||||
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',
|
||||
]);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
|
||||
}
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
if ($pull_request_id !== 0) {
|
||||
if ($git_type === 'gitlab') {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
|
||||
}
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1637,43 +1784,49 @@ class Application extends BaseModel
|
||||
$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);
|
||||
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
|
||||
if ($gitConfigOptions) {
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
}
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
$otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
|
||||
if ($git_type === 'gitlab') {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1760,8 +1913,10 @@ class Application extends BaseModel
|
||||
if ($isInit && $this->docker_compose_raw) {
|
||||
return;
|
||||
}
|
||||
$uuid = new Cuid2;
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.');
|
||||
$uuid = new_public_id();
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
|
||||
$cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand);
|
||||
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$composeFile = $this->docker_compose_location;
|
||||
$fileList = collect([".$workdir$composeFile"]);
|
||||
@@ -1791,6 +1946,7 @@ class Application extends BaseModel
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
$cloneCommand,
|
||||
'cd checkout',
|
||||
'git sparse-checkout init',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
@@ -1802,6 +1958,7 @@ class Application extends BaseModel
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
$cloneCommand,
|
||||
'cd checkout',
|
||||
'git sparse-checkout init --cone',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
@@ -1911,13 +2068,15 @@ class Application extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildGitCheckoutCommand($target): string
|
||||
protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string
|
||||
{
|
||||
$escapedTarget = escapeshellarg($target);
|
||||
$command = "git checkout {$escapedTarget}";
|
||||
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
|
||||
$command = "{$gitCommand} checkout {$escapedTarget}";
|
||||
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
$command .= ' && git submodule update --init --recursive';
|
||||
$sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
$command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive";
|
||||
}
|
||||
|
||||
return $command;
|
||||
@@ -2232,7 +2391,7 @@ class Application extends BaseModel
|
||||
'config.build_pack' => 'required|string',
|
||||
'config.base_directory' => 'required|string',
|
||||
'config.publish_directory' => 'required|string',
|
||||
'config.ports_exposes' => 'required|string',
|
||||
'config.ports_exposes' => 'nullable|string',
|
||||
'config.settings.is_static' => 'required|boolean',
|
||||
]);
|
||||
if ($deepValidator->fails()) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\EncryptedArrayCast;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -16,6 +17,10 @@ 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],
|
||||
'configuration_hash' => ['type' => 'string', 'nullable' => true],
|
||||
'configuration_snapshot' => ['type' => 'object', 'nullable' => true],
|
||||
'configuration_diff' => ['type' => 'object', 'nullable' => true],
|
||||
'force_rebuild' => ['type' => 'boolean'],
|
||||
'commit' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
@@ -39,10 +44,55 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class ApplicationDeploymentQueue extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'deployment_uuid',
|
||||
'pull_request_id',
|
||||
'docker_registry_image_tag',
|
||||
'configuration_hash',
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
* The configuration snapshot/diff hold full (decrypted on read) configuration,
|
||||
* including unlocked environment variable values. They are only meant for the
|
||||
* in-app diff modal (which redacts per role) and must never be serialized by the
|
||||
* API, so hide them globally as defense in depth.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
'configuration_snapshot' => EncryptedArrayCast::class,
|
||||
'configuration_diff' => EncryptedArrayCast::class,
|
||||
];
|
||||
|
||||
public function application()
|
||||
|
||||
@@ -2,15 +2,31 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Url\Url;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -26,11 +42,18 @@ class ApplicationPreview extends BaseModel
|
||||
$networkKeys = collect($networks)->keys();
|
||||
$volumeKeys = collect($volumes)->keys();
|
||||
$volumeKeys->each(function ($key) use ($server) {
|
||||
instant_remote_process(["docker volume rm -f $key"], $server, false);
|
||||
if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) {
|
||||
return;
|
||||
}
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false);
|
||||
});
|
||||
$networkKeys->each(function ($key) use ($server) {
|
||||
instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false);
|
||||
instant_remote_process(["docker network rm $key"], $server, false);
|
||||
if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) {
|
||||
return;
|
||||
}
|
||||
$k = escapeshellarg($key);
|
||||
instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false);
|
||||
instant_remote_process(["docker network rm {$k}"], $server, false);
|
||||
});
|
||||
} else {
|
||||
// Regular application volume cleanup
|
||||
@@ -47,7 +70,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 +92,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()
|
||||
@@ -87,7 +110,7 @@ class ApplicationPreview extends BaseModel
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$urlPath = $url->getPath();
|
||||
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||
$random = new Cuid2;
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
||||
@@ -149,7 +172,7 @@ class ApplicationPreview extends BaseModel
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$urlPath = $url->getPath();
|
||||
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||
$random = new Cuid2;
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
||||
|
||||
@@ -26,9 +26,68 @@ class ApplicationSetting extends Model
|
||||
'is_git_lfs_enabled' => 'boolean',
|
||||
'is_git_shallow_clone_enabled' => 'boolean',
|
||||
'docker_images_to_keep' => 'integer',
|
||||
'stop_grace_period' => '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',
|
||||
'stop_grace_period',
|
||||
];
|
||||
|
||||
public function stopGracePeriodSeconds(): int
|
||||
{
|
||||
if (
|
||||
$this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS &&
|
||||
$this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS
|
||||
) {
|
||||
return $this->stop_grace_period;
|
||||
}
|
||||
|
||||
return DEFAULT_STOP_GRACE_PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
public function deploymentStopGracePeriodSeconds(): int
|
||||
{
|
||||
if (isDev() && $this->stop_grace_period === null) {
|
||||
return MIN_STOP_GRACE_PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
return $this->stopGracePeriodSeconds();
|
||||
}
|
||||
|
||||
public function isStatic(): Attribute
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
abstract class BaseModel extends Model
|
||||
{
|
||||
@@ -15,7 +14,7 @@ abstract class BaseModel extends Model
|
||||
static::creating(function (Model $model) {
|
||||
// Generate a UUID if one isn't set
|
||||
if (! $model->uuid) {
|
||||
$model->uuid = (string) new Cuid2;
|
||||
$model->uuid = new_public_id();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class CloudProviderToken extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
use HasFactory;
|
||||
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -32,6 +34,8 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class EnvironmentVariable extends BaseModel
|
||||
{
|
||||
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
|
||||
|
||||
protected $attributes = [
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
@@ -74,11 +78,11 @@ class EnvironmentVariable extends BaseModel
|
||||
'resourceable_id' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
|
||||
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::created(function (EnvironmentVariable $environment_variable) {
|
||||
static::created(function (ModelsEnvironmentVariable $environment_variable) {
|
||||
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
|
||||
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
|
||||
->where('resourceable_type', Application::class)
|
||||
@@ -109,7 +113,7 @@ class EnvironmentVariable extends BaseModel
|
||||
]);
|
||||
});
|
||||
|
||||
static::saving(function (EnvironmentVariable $environmentVariable) {
|
||||
static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
|
||||
$environmentVariable->updateIsShared();
|
||||
});
|
||||
}
|
||||
@@ -119,6 +123,30 @@ class EnvironmentVariable extends BaseModel
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
|
||||
{
|
||||
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
|
||||
$query->where('key', 'not like', "{$prefix}%");
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function isBuildpackControlKey(?string $key): bool
|
||||
{
|
||||
if (blank($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
|
||||
if (str($key)->startsWith($prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function value(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -152,6 +180,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)
|
||||
@@ -177,16 +216,10 @@ class EnvironmentVariable extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
protected function isNixpacks(): Attribute
|
||||
protected function isBuildpackControl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (str($this->key)->startsWith('NIXPACKS_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
get: fn () => self::isBuildpackControlKey($this->key),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,9 +250,99 @@ 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)
|
||||
{
|
||||
return resolveSharedEnvironmentVariables($environment_variable, $resource);
|
||||
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('/{{(.*?)}}/');
|
||||
if ($sharedEnvsFound->isEmpty()) {
|
||||
return $environment_variable;
|
||||
}
|
||||
foreach ($sharedEnvsFound as $sharedEnv) {
|
||||
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
|
||||
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
|
||||
continue;
|
||||
}
|
||||
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
|
||||
$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;
|
||||
}
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
return str($environment_variable)->value();
|
||||
}
|
||||
|
||||
private function get_environment_variables(?string $environment_variable = null): ?string
|
||||
@@ -233,7 +356,7 @@ class EnvironmentVariable extends BaseModel
|
||||
|
||||
private function set_environment_variables(?string $environment_variable = null): ?string
|
||||
{
|
||||
if (is_null($environment_variable) && $environment_variable === '') {
|
||||
if (is_null($environment_variable)) {
|
||||
return null;
|
||||
}
|
||||
$environment_variable = trim($environment_variable);
|
||||
@@ -248,7 +371,9 @@ class EnvironmentVariable extends BaseModel
|
||||
protected function key(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
|
||||
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
|
||||
ValidationPatterns::normalizeEnvironmentVariableKey($value)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+22
-22
@@ -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'];
|
||||
|
||||
@@ -53,26 +73,6 @@ class GithubApp extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function public()
|
||||
{
|
||||
return GithubApp::where(function ($query) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('team_id', currentTeam()->id)
|
||||
->orWhere('is_system_wide', true);
|
||||
})->where('is_public', true);
|
||||
})->whereNotNull('app_id')->get();
|
||||
}
|
||||
|
||||
public static function private()
|
||||
{
|
||||
return GithubApp::where(function ($query) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('team_id', currentTeam()->id)
|
||||
->orWhere('is_system_wide', true);
|
||||
})->where('is_public', false);
|
||||
})->whereNotNull('app_id')->get();
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
@@ -92,7 +92,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,46 @@ 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',
|
||||
'is_mcp_server_enabled',
|
||||
'webhook_allowed_internal_hosts',
|
||||
'webhook_allow_localhost',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'smtp_enabled' => 'boolean',
|
||||
@@ -31,10 +70,17 @@ class InstanceSettings extends Model
|
||||
'update_check_frequency' => 'string',
|
||||
'sentinel_token' => 'encrypted',
|
||||
'is_wire_navigate_enabled' => 'boolean',
|
||||
'is_mcp_server_enabled' => 'boolean',
|
||||
'webhook_allowed_internal_hosts' => 'array',
|
||||
'webhook_allow_localhost' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function () {
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
static::updated(function ($settings) {
|
||||
// Clear once() cache so subsequent calls get fresh data
|
||||
Once::flush();
|
||||
|
||||
@@ -10,23 +10,46 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalFileVolume extends BaseModel
|
||||
{
|
||||
public const MAX_CONTENT_SIZE = 5_242_880;
|
||||
|
||||
public const BINARY_PLACEHOLDER = '[binary file]';
|
||||
|
||||
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
|
||||
|
||||
protected $casts = [
|
||||
// 'fs_path' => 'encrypted',
|
||||
// 'mount_path' => 'encrypted',
|
||||
'content' => 'encrypted',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'fs_path',
|
||||
'mount_path',
|
||||
'content',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'is_directory',
|
||||
'is_host_file',
|
||||
'chown',
|
||||
'chmod',
|
||||
'is_based_on_git',
|
||||
'is_preview_suffix_enabled',
|
||||
];
|
||||
|
||||
public $appends = ['is_binary'];
|
||||
public $appends = ['is_binary', 'is_too_large'];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::created(function (LocalFileVolume $fileVolume) {
|
||||
if ($fileVolume->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fileVolume->load(['service']);
|
||||
dispatch(new ServerStorageSaveJob($fileVolume));
|
||||
});
|
||||
@@ -35,9 +58,14 @@ class LocalFileVolume extends BaseModel
|
||||
protected function isBinary(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
return $this->content === '[binary file]';
|
||||
}
|
||||
get: fn () => $this->content === self::BINARY_PLACEHOLDER
|
||||
);
|
||||
}
|
||||
|
||||
protected function isTooLarge(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +76,10 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
public function loadStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load(['service']);
|
||||
$isService = data_get($this->resource, 'service');
|
||||
if ($isService) {
|
||||
@@ -70,10 +102,17 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
||||
if ($isFile === 'OK') {
|
||||
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
||||
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
||||
$this->is_directory = false;
|
||||
$this->save();
|
||||
|
||||
return;
|
||||
}
|
||||
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
|
||||
// Check if content contains binary data by looking for null bytes or non-printable characters
|
||||
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
|
||||
$content = '[binary file]';
|
||||
$content = self::BINARY_PLACEHOLDER;
|
||||
}
|
||||
$this->content = $content;
|
||||
$this->is_directory = false;
|
||||
@@ -81,8 +120,24 @@ class LocalFileVolume extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
|
||||
{
|
||||
$sizeOutput = instant_remote_process(
|
||||
["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
|
||||
$server,
|
||||
false,
|
||||
);
|
||||
$size = (int) trim((string) $sizeOutput);
|
||||
|
||||
return $size > self::MAX_CONTENT_SIZE;
|
||||
}
|
||||
|
||||
public function deleteStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load(['service']);
|
||||
$isService = data_get($this->resource, 'service');
|
||||
if ($isService) {
|
||||
@@ -120,6 +175,10 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
public function saveStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load(['service']);
|
||||
$isService = data_get($this->resource, 'service');
|
||||
if ($isService) {
|
||||
@@ -130,26 +189,26 @@ 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) {
|
||||
// Validate fs_path early before any shell interpolation
|
||||
validateShellSafePath($this->fs_path, 'storage path');
|
||||
$escapedFsPath = escapeshellarg($this->fs_path);
|
||||
$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('/');
|
||||
$path = data_get_str($this, 'fs_path');
|
||||
$content = data_get($this, 'content');
|
||||
$pathForParentDirectory = str($this->fs_path);
|
||||
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
|
||||
$parent_dir = $pathForParentDirectory->beforeLast('/');
|
||||
if ($parent_dir != '') {
|
||||
$escapedParentDir = escapeshellarg($parent_dir);
|
||||
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
||||
}
|
||||
}
|
||||
$path = data_get_str($this, 'fs_path');
|
||||
$content = data_get($this, 'content');
|
||||
if ($path->startsWith('.')) {
|
||||
$path = $path->after('.');
|
||||
$path = $workdir.$path;
|
||||
@@ -162,9 +221,12 @@ class LocalFileVolume extends BaseModel
|
||||
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
||||
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
|
||||
if ($isFile === 'OK' && $this->is_directory) {
|
||||
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
|
||||
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
||||
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
||||
} else {
|
||||
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
|
||||
}
|
||||
$this->is_directory = false;
|
||||
$this->content = $content;
|
||||
$this->save();
|
||||
FileStorageChanged::dispatch(data_get($server, 'team_id'));
|
||||
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
|
||||
@@ -304,7 +366,6 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
ray($e->getMessage(), 'Error checking read-only volume');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
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',
|
||||
@@ -179,7 +187,6 @@ class LocalPersistentVolume extends BaseModel
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
ray($e->getMessage(), 'Error checking read-only persistent volume');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
|
||||
'token',
|
||||
'abilities',
|
||||
'expires_at',
|
||||
'api_token_expiration_warning_sent_at',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_token_expiration_warning_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -30,7 +31,7 @@ use phpseclib3\Crypt\PublicKeyLoader;
|
||||
)]
|
||||
class PrivateKey extends BaseModel
|
||||
{
|
||||
use HasSafeStringAttribute, WithRateLimiting;
|
||||
use HasFactory, HasSafeStringAttribute, WithRateLimiting;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Project model',
|
||||
@@ -24,7 +23,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.
|
||||
@@ -54,7 +58,7 @@ class Project extends BaseModel
|
||||
Environment::create([
|
||||
'name' => 'production',
|
||||
'project_id' => $project->id,
|
||||
'uuid' => (string) new Cuid2,
|
||||
'uuid' => new_public_id(),
|
||||
]);
|
||||
});
|
||||
static::deleting(function ($project) {
|
||||
@@ -69,7 +73,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',
|
||||
|
||||
+80
-14
@@ -2,17 +2,35 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class S3Storage extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
private const CONNECTION_TIMEOUT_SECONDS = 15;
|
||||
|
||||
private const REQUEST_TIMEOUT_SECONDS = 15;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'name',
|
||||
'description',
|
||||
'region',
|
||||
'key',
|
||||
'secret',
|
||||
'bucket',
|
||||
'endpoint',
|
||||
'is_usable',
|
||||
'unusable_email_sent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_usable' => 'boolean',
|
||||
@@ -56,6 +74,13 @@ class S3Storage extends BaseModel
|
||||
return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name');
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*'])
|
||||
{
|
||||
$selectArray = collect($select)->concat(['id']);
|
||||
|
||||
return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name');
|
||||
}
|
||||
|
||||
public function isUsable()
|
||||
{
|
||||
return $this->is_usable;
|
||||
@@ -122,6 +147,24 @@ class S3Storage extends BaseModel
|
||||
public function testConnection(bool $shouldSave = false)
|
||||
{
|
||||
try {
|
||||
$validator = Validator::make(
|
||||
[
|
||||
'endpoint' => $this['endpoint'],
|
||||
'bucket' => $this['bucket'],
|
||||
],
|
||||
[
|
||||
'endpoint' => ['required', new SafeWebhookUrl],
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
],
|
||||
);
|
||||
$validator->fails();
|
||||
if ($validator->errors()->has('endpoint')) {
|
||||
throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
|
||||
}
|
||||
if ($validator->errors()->has('bucket')) {
|
||||
throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
|
||||
}
|
||||
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
'region' => $this['region'],
|
||||
@@ -130,6 +173,10 @@ class S3Storage extends BaseModel
|
||||
'bucket' => $this['bucket'],
|
||||
'endpoint' => $this['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
|
||||
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
|
||||
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
|
||||
]),
|
||||
]);
|
||||
// Test the connection by listing files with ListObjectsV2 (S3)
|
||||
$disk->files();
|
||||
@@ -137,30 +184,49 @@ class S3Storage extends BaseModel
|
||||
$this->unusable_email_sent = false;
|
||||
$this->is_usable = true;
|
||||
} catch (\Throwable $e) {
|
||||
$exception = $this->toUserFriendlyConnectionException($e);
|
||||
$this->is_usable = false;
|
||||
if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) {
|
||||
$mail = new MailMessage;
|
||||
$mail->subject('Coolify: S3 Storage Connection Error');
|
||||
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
|
||||
try {
|
||||
$mail = new MailMessage;
|
||||
$mail->subject('Coolify: S3 Storage Connection Error');
|
||||
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
|
||||
|
||||
// Load the team with its members and their roles explicitly
|
||||
$team = $this->team()->with(['members' => function ($query) {
|
||||
$query->withPivot('role');
|
||||
}])->first();
|
||||
// Load the team with its members and their roles explicitly
|
||||
$team = $this->team()->with(['members' => function ($query) {
|
||||
$query->withPivot('role');
|
||||
}])->first();
|
||||
|
||||
// Get admins directly from the pivot relationship for this specific team
|
||||
$users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']);
|
||||
foreach ($users as $user) {
|
||||
send_user_an_email($mail, $user->email);
|
||||
// Get admins directly from the pivot relationship for this specific team
|
||||
$users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']);
|
||||
foreach ($users as $user) {
|
||||
send_user_an_email($mail, $user->email);
|
||||
}
|
||||
$this->unusable_email_sent = true;
|
||||
} catch (\Throwable $emailException) {
|
||||
\Log::warning('Failed to send S3 connection error notification: '.$emailException->getMessage());
|
||||
}
|
||||
$this->unusable_email_sent = true;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
throw $exception;
|
||||
} finally {
|
||||
if ($shouldSave) {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable
|
||||
{
|
||||
$message = str($exception->getMessage())->lower();
|
||||
|
||||
if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) {
|
||||
return new \RuntimeException(
|
||||
'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.',
|
||||
previous: $exception,
|
||||
);
|
||||
}
|
||||
|
||||
return $exception;
|
||||
}
|
||||
}
|
||||
@@ -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,11 +6,24 @@ 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
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
's3_uploaded' => 'boolean',
|
||||
'local_storage_deleted' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -65,20 +76,14 @@ class ScheduledTask extends BaseModel
|
||||
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function server()
|
||||
public function server(): ?Server
|
||||
{
|
||||
if ($this->application) {
|
||||
if ($this->application->destination && $this->application->destination->server) {
|
||||
return $this->application->destination->server;
|
||||
}
|
||||
} elseif ($this->service) {
|
||||
if ($this->service->destination && $this->service->destination->server) {
|
||||
return $this->service->destination->server;
|
||||
}
|
||||
} elseif ($this->database) {
|
||||
if ($this->database->destination && $this->database->destination->server) {
|
||||
return $this->database->destination->server;
|
||||
}
|
||||
return $this->application->destination?->server;
|
||||
}
|
||||
|
||||
if ($this->service) {
|
||||
return $this->service->destination?->server;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+56
-45
@@ -17,6 +17,7 @@ use App\Livewire\Server\Proxy;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
@@ -34,8 +35,8 @@ 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;
|
||||
|
||||
/**
|
||||
* @property array{
|
||||
@@ -134,7 +135,7 @@ class Server extends BaseModel
|
||||
$payload['ip_previous'] = $server->getOriginal('ip');
|
||||
}
|
||||
}
|
||||
$server->forceFill($payload);
|
||||
$server->fill($payload);
|
||||
});
|
||||
static::saved(function ($server) {
|
||||
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
|
||||
@@ -147,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()) {
|
||||
@@ -169,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)) {
|
||||
@@ -263,6 +273,7 @@ class Server extends BaseModel
|
||||
'detected_traefik_version',
|
||||
'traefik_outdated_info',
|
||||
'server_metadata',
|
||||
'ip_previous',
|
||||
];
|
||||
|
||||
use HasSafeStringAttribute;
|
||||
@@ -270,7 +281,7 @@ class Server extends BaseModel
|
||||
public function setValidationLogsAttribute($value): void
|
||||
{
|
||||
$this->attributes['validation_logs'] = $value !== null
|
||||
? \Stevebauman\Purify\Facades\Purify::config('validation_logs')->clean($value)
|
||||
? Purify::config('validation_logs')->clean($value)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -934,10 +945,10 @@ $schema://$host {
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
|
||||
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
|
||||
},
|
||||
set: function ($value) {
|
||||
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
|
||||
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1022,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' => new_public_id(),
|
||||
'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?
|
||||
@@ -1201,10 +1236,8 @@ $schema://$host {
|
||||
$this->refresh();
|
||||
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
|
||||
$isReachable = (bool) $this->settings->is_reachable;
|
||||
if ($isReachable === true) {
|
||||
$this->unreachable_count = 0;
|
||||
$this->save();
|
||||
|
||||
if ($isReachable === true) {
|
||||
if ($unreachableNotificationSent === true) {
|
||||
$this->sendReachableNotification();
|
||||
}
|
||||
@@ -1212,28 +1245,8 @@ $schema://$host {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->increment('unreachable_count');
|
||||
|
||||
if ($this->unreachable_count === 1) {
|
||||
$this->settings->is_reachable = true;
|
||||
$this->settings->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
|
||||
$failedChecks = 0;
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$status = $this->serverStatus();
|
||||
sleep(5);
|
||||
if (! $status) {
|
||||
$failedChecks++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedChecks === 3 && ! $unreachableNotificationSent) {
|
||||
$this->sendUnreachableNotification();
|
||||
}
|
||||
$this->sendUnreachableNotification();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1510,7 +1523,6 @@ $schema://$host {
|
||||
public function generateCaCertificate()
|
||||
{
|
||||
try {
|
||||
ray('Generating CA certificate for server', $this->id);
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: 'Coolify CA Certificate',
|
||||
serverId: $this->id,
|
||||
@@ -1518,7 +1530,6 @@ $schema://$host {
|
||||
validityDays: 10 * 365
|
||||
);
|
||||
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
ray('CA certificate generated', $caCertificate);
|
||||
if ($caCertificate) {
|
||||
$certificateContent = $caCertificate->ssl_certificate;
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -49,11 +50,57 @@ use OpenApi\Attributes as OA;
|
||||
'updated_at' => ['type' => 'string'],
|
||||
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
|
||||
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
|
||||
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
|
||||
]
|
||||
)]
|
||||
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',
|
||||
'connection_timeout',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'force_disabled' => 'boolean',
|
||||
@@ -64,6 +111,7 @@ class ServerSetting extends Model
|
||||
'is_usable' => 'boolean',
|
||||
'is_terminal_enabled' => 'boolean',
|
||||
'disable_application_image_retention' => 'boolean',
|
||||
'connection_timeout' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
@@ -97,19 +145,54 @@ 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
|
||||
public static function isValidSentinelToken(?string $token): bool
|
||||
{
|
||||
if ($token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
||||
}
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
|
||||
/**
|
||||
* Returns a valid sentinel token, regenerating it if the stored value is
|
||||
* empty, undecryptable, or otherwise invalid. Throws only when regeneration
|
||||
* still fails to produce a valid token.
|
||||
*/
|
||||
public function ensureValidSentinelToken(): string
|
||||
{
|
||||
try {
|
||||
$token = $this->sentinel_token;
|
||||
} catch (DecryptException) {
|
||||
$token = null;
|
||||
}
|
||||
|
||||
if (! self::isValidSentinelToken($token)) {
|
||||
// Clear undecryptable raw value so Eloquent's dirty-check won't try to
|
||||
// decrypt the bad original during save().
|
||||
$attrs = $this->getAttributes();
|
||||
$attrs['sentinel_token'] = null;
|
||||
$this->setRawAttributes($attrs, true);
|
||||
|
||||
$this->generateSentinelToken(save: true, ignoreEvent: true);
|
||||
$this->refresh();
|
||||
$token = $this->sentinel_token;
|
||||
}
|
||||
|
||||
if (! self::isValidSentinelToken($token)) {
|
||||
throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
|
||||
{
|
||||
$data = [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
];
|
||||
$token = json_encode($data);
|
||||
$encrypted = encrypt($token);
|
||||
$this->sentinel_token = $encrypted;
|
||||
$token = encrypt(json_encode($data));
|
||||
$this->sentinel_token = $token;
|
||||
if ($save) {
|
||||
if ($ignoreEvent) {
|
||||
$this->saveQuietly();
|
||||
|
||||
+20
-6
@@ -16,7 +16,6 @@ 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(
|
||||
description: 'Service model',
|
||||
@@ -49,6 +48,7 @@ class Service extends BaseModel
|
||||
private static $parserVersion = '5';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'docker_compose_raw',
|
||||
@@ -57,6 +57,11 @@ class Service extends BaseModel
|
||||
'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'];
|
||||
@@ -65,7 +70,7 @@ class Service extends BaseModel
|
||||
{
|
||||
static::creating(function ($service) {
|
||||
if (blank($service->name)) {
|
||||
$service->name = 'service-'.(new Cuid2);
|
||||
$service->name = 'service-'.new_public_id();
|
||||
}
|
||||
});
|
||||
static::created(function ($service) {
|
||||
@@ -620,7 +625,7 @@ class Service extends BaseModel
|
||||
}
|
||||
$fields->put('Unleash', $data->toArray());
|
||||
break;
|
||||
case $image->contains('grafana'):
|
||||
case $this->isGrafanaImage($image->toString()):
|
||||
$data = collect([]);
|
||||
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first();
|
||||
$data = $data->merge([
|
||||
@@ -772,7 +777,8 @@ class Service extends BaseModel
|
||||
}
|
||||
$rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first();
|
||||
if (is_null($rpc_secret)) {
|
||||
$rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
|
||||
$rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_64_RPCSECRET')->first()
|
||||
?? $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
|
||||
}
|
||||
$metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first();
|
||||
if (is_null($metrics_token)) {
|
||||
@@ -1373,6 +1379,15 @@ class Service extends BaseModel
|
||||
return $fields;
|
||||
}
|
||||
|
||||
private function isGrafanaImage(string $image): bool
|
||||
{
|
||||
return in_array($image, [
|
||||
'grafana/grafana',
|
||||
'grafana/grafana-oss',
|
||||
'grafana/grafana-enterprise',
|
||||
], true);
|
||||
}
|
||||
|
||||
public function saveExtraFields($fields)
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
@@ -1548,7 +1563,7 @@ class Service extends BaseModel
|
||||
"cd $workdir",
|
||||
], $this->server);
|
||||
|
||||
$filename = new Cuid2.'-docker-compose.yml';
|
||||
$filename = new_public_id().'-docker-compose.yml';
|
||||
Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose);
|
||||
$path = Storage::path("tmp/{$filename}");
|
||||
instant_scp($path, "{$workdir}/docker-compose.yml", $this->server);
|
||||
@@ -1568,7 +1583,6 @@ class Service extends BaseModel
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
ray($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SharedEnvironmentVariable extends Model
|
||||
@@ -17,11 +19,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 = [
|
||||
@@ -29,6 +35,13 @@ class SharedEnvironmentVariable extends Model
|
||||
'value' => 'encrypted',
|
||||
];
|
||||
|
||||
protected function key(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
|
||||
);
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
@@ -43,4 +56,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',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'clickhouse_admin_user',
|
||||
@@ -37,11 +39,27 @@ class StandaloneClickhouse extends BaseModel
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'custom_docker_run_options',
|
||||
'clickhouse_db',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'clickhouse_admin_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
@@ -68,7 +86,7 @@ class StandaloneClickhouse extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -104,6 +122,7 @@ class StandaloneClickhouse extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use App\Jobs\ConnectProxyToNetworksJob;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class StandaloneDocker extends BaseModel
|
||||
@@ -12,7 +13,11 @@ class StandaloneDocker extends BaseModel
|
||||
use HasFactory;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'name',
|
||||
'network',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
@@ -86,6 +91,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.
|
||||
@@ -113,19 +128,22 @@ class StandaloneDocker extends BaseModel
|
||||
return $this->morphMany(Service::class, 'destination');
|
||||
}
|
||||
|
||||
public function databases()
|
||||
public function databases(): Collection
|
||||
{
|
||||
$postgresqls = $this->postgresqls;
|
||||
$redis = $this->redis;
|
||||
$mongodbs = $this->mongodbs;
|
||||
$mysqls = $this->mysqls;
|
||||
$mariadbs = $this->mariadbs;
|
||||
$keydbs = $this->keydbs;
|
||||
$dragonflies = $this->dragonflies;
|
||||
$clickhouses = $this->clickhouses;
|
||||
|
||||
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs);
|
||||
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
|
||||
}
|
||||
|
||||
public function attachedTo()
|
||||
{
|
||||
return $this->applications?->count() > 0 || $this->databases()->count() > 0;
|
||||
return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneDragonfly extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'dragonfly_password',
|
||||
@@ -36,11 +38,27 @@ class StandaloneDragonfly extends BaseModel
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'dragonfly_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
@@ -67,7 +85,7 @@ class StandaloneDragonfly extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -103,6 +121,7 @@ class StandaloneDragonfly extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneKeydb extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'keydb_password',
|
||||
@@ -37,11 +39,27 @@ class StandaloneKeydb extends BaseModel
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'last_online_at',
|
||||
'public_port_timeout',
|
||||
'enable_ssl',
|
||||
'custom_docker_run_options',
|
||||
'destination_type',
|
||||
'destination_id',
|
||||
'environment_id',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'keydb_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
@@ -68,7 +86,7 @@ class StandaloneKeydb extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -104,6 +122,7 @@ class StandaloneKeydb extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -12,9 +13,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMariadb extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'mariadb_root_password',
|
||||
@@ -39,11 +41,28 @@ class StandaloneMariadb extends BaseModel
|
||||
'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',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'mariadb_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
@@ -70,7 +89,7 @@ class StandaloneMariadb extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -106,6 +125,7 @@ class StandaloneMariadb extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMongodb extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'mongo_conf',
|
||||
@@ -37,11 +39,30 @@ class StandaloneMongodb extends BaseModel
|
||||
'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',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
@@ -74,7 +95,7 @@ class StandaloneMongodb extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -110,6 +131,7 @@ class StandaloneMongodb extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMysql extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'mysql_root_password',
|
||||
@@ -38,11 +40,30 @@ class StandaloneMysql extends BaseModel
|
||||
'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',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'mysql_password' => 'encrypted',
|
||||
'mysql_root_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
@@ -70,7 +91,7 @@ class StandaloneMysql extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -106,6 +127,7 @@ class StandaloneMysql extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandalonePostgresql extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'postgres_user',
|
||||
@@ -40,11 +42,30 @@ class StandalonePostgresql extends BaseModel
|
||||
'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',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'init_scripts' => 'array',
|
||||
'postgres_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
@@ -86,7 +107,7 @@ class StandalonePostgresql extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -148,6 +169,7 @@ class StandalonePostgresql extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneRedis extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'redis_conf',
|
||||
@@ -34,11 +36,29 @@ class StandaloneRedis extends BaseModel
|
||||
'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',
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer',
|
||||
'health_check_timeout' => 'integer',
|
||||
'health_check_retries' => 'integer',
|
||||
'health_check_start_period' => 'integer',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
@@ -64,7 +84,7 @@ class StandaloneRedis extends BaseModel
|
||||
});
|
||||
static::saving(function ($database) {
|
||||
if ($database->isDirty('status')) {
|
||||
$database->forceFill(['last_online_at' => now()]);
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,6 +126,7 @@ class StandaloneRedis extends BaseModel
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf;
|
||||
$newConfigHash .= $this->healthCheckConfigurationHash();
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -6,7 +6,11 @@ use App\Support\ValidationPatterns;
|
||||
|
||||
class SwarmDocker extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'name',
|
||||
'network',
|
||||
];
|
||||
|
||||
public function setNetworkAttribute(string $value): void
|
||||
{
|
||||
@@ -67,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.
|
||||
@@ -110,6 +124,6 @@ class SwarmDocker extends BaseModel
|
||||
|
||||
public function attachedTo()
|
||||
{
|
||||
return $this->applications?->count() > 0 || $this->databases()->count() > 0;
|
||||
return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -19,7 +19,10 @@ class Tag extends BaseModel
|
||||
{
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
protected function customizeName($value)
|
||||
{
|
||||
|
||||
+28
-14
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\User\RevokeUserTeamTokens;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Notifications\Channels\SendsDiscord;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
@@ -46,10 +47,12 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
'personal_team',
|
||||
'show_boarding',
|
||||
'custom_server_limit',
|
||||
'is_mcp_server_enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'personal_team' => 'boolean',
|
||||
'is_mcp_server_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
@@ -65,31 +68,39 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
$team->webhookNotificationSettings()->create();
|
||||
});
|
||||
|
||||
static::saving(function ($team) {
|
||||
static::updating(function ($team) {
|
||||
if (auth()->user()?->isMember()) {
|
||||
throw new \Exception('You are not allowed to update this team.');
|
||||
}
|
||||
});
|
||||
|
||||
static::deleting(function ($team) {
|
||||
$keys = $team->privateKeys;
|
||||
foreach ($keys as $key) {
|
||||
static::deleting(function (Team $team) {
|
||||
RevokeUserTeamTokens::forTeam($team->id);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -227,12 +238,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',
|
||||
|
||||
+34
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\User\RevokeUserTeamTokens;
|
||||
use App\Jobs\UpdateStripeCustomerEmailJob;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
use App\Notifications\TransactionalEmails\EmailChangeVerification;
|
||||
@@ -49,6 +50,9 @@ class User extends Authenticatable implements SendsEmail
|
||||
'password',
|
||||
'force_password_reset',
|
||||
'marketing_emails',
|
||||
'pending_email',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -95,12 +99,31 @@ class User extends Authenticatable implements SendsEmail
|
||||
$team['id'] = 0;
|
||||
$team['name'] = 'Root Team';
|
||||
}
|
||||
$new_team = Team::forceCreate($team);
|
||||
$new_team = $user->id === 0 ? Team::find(0) : null;
|
||||
|
||||
if ($new_team !== null) {
|
||||
$new_team->forceFill($team);
|
||||
$new_team->save();
|
||||
|
||||
if (! $user->teams()->whereKey($new_team->id)->exists()) {
|
||||
$user->teams()->attach($new_team, ['role' => 'owner']);
|
||||
} else {
|
||||
$user->teams()->updateExistingPivot($new_team->id, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$new_team = (new Team)->forceFill($team);
|
||||
$new_team->save();
|
||||
|
||||
$user->teams()->attach($new_team, ['role' => 'owner']);
|
||||
});
|
||||
|
||||
static::deleting(function (User $user) {
|
||||
\DB::transaction(function () use ($user) {
|
||||
RevokeUserTeamTokens::forUser($user);
|
||||
|
||||
$teams = $user->teams;
|
||||
foreach ($teams as $team) {
|
||||
$user_alone_in_team = $team->members->count() === 1;
|
||||
@@ -138,6 +161,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
if ($found_other_member_who_is_not_owner) {
|
||||
$found_other_member_who_is_not_owner->pivot->role = 'owner';
|
||||
$found_other_member_who_is_not_owner->pivot->save();
|
||||
RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id);
|
||||
$team->members()->detach($user->id);
|
||||
} else {
|
||||
static::finalizeTeamDeletion($user, $team);
|
||||
@@ -198,7 +222,8 @@ class User extends Authenticatable implements SendsEmail
|
||||
$team['id'] = 0;
|
||||
$team['name'] = 'Root Team';
|
||||
}
|
||||
$new_team = Team::forceCreate($team);
|
||||
$new_team = (new Team)->forceFill($team);
|
||||
$new_team->save();
|
||||
$this->teams()->attach($new_team, ['role' => 'owner']);
|
||||
|
||||
return $new_team;
|
||||
@@ -252,7 +277,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
|
||||
[
|
||||
'id' => $this->getKey(),
|
||||
'hash' => sha1($this->getEmailForVerification()),
|
||||
'hash' => hash('sha256', $this->getEmailForVerification()),
|
||||
]
|
||||
);
|
||||
$mail->view('emails.email-verification', [
|
||||
@@ -324,6 +349,11 @@ class User extends Authenticatable implements SendsEmail
|
||||
{
|
||||
$sessionTeamId = data_get(session('currentTeam'), 'id');
|
||||
|
||||
// Fallback for stateless API requests: resolve team from Sanctum token
|
||||
if (is_null($sessionTeamId) && $this->currentAccessToken()) {
|
||||
$sessionTeamId = data_get($this->currentAccessToken(), 'team_id');
|
||||
}
|
||||
|
||||
if (is_null($sessionTeamId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -409,7 +439,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
|
||||
$expiresAt = Carbon::now()->addMinutes($expiryMinutes);
|
||||
|
||||
$this->forceFill([
|
||||
$this->fill([
|
||||
'pending_email' => $newEmail,
|
||||
'email_change_code' => $code,
|
||||
'email_change_code_expires_at' => $expiresAt,
|
||||
|
||||
Reference in new issue
Block a user