Merge remote-tracking branch 'origin/next' into feat/api/tag-management

# Conflicts:
#	app/Http/Controllers/Api/ApplicationsController.php
This commit is contained in:
Andras Bacsai committed 2026-07-07 12:49:19 +02:00
commit f617e58401
1196 files changed
+69504 -18702

No files matched your search

+338 -179
View File
@@ -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()) {
+51 -1
View File
@@ -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()
+32 -9
View File
@@ -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);
+60 -1
View File
@@ -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
{
+1 -2
View File
@@ -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();
}
});
}
+10 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+7 -1
View File
@@ -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
{
+4
View File
@@ -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',
];
+7 -2
View File
@@ -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()
+139 -14
View File
@@ -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
View File
@@ -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';
}
},
+18
View File
@@ -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',
+47 -1
View File
@@ -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();
+78 -17
View File
@@ -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;
}
+9 -2
View File
@@ -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;
}
+8
View File
@@ -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',
];
}
}
+2 -1
View File
@@ -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',
+8 -4
View File
@@ -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()
+3 -1
View File
@@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Model;
class ProjectSetting extends Model
{
protected $guarded = [];
protected $fillable = [
'project_id',
];
public function project()
{
+2 -1
View File
@@ -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
View File
@@ -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;
}
}
+29 -1
View File
@@ -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',
+18 -13
View File
@@ -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;
+10 -1
View File
@@ -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
View File
@@ -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/';
+89 -6
View File
@@ -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
View File
@@ -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());
}
}
+22 -3
View File
@@ -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();
+23 -2
View File
@@ -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();
}
});
}
+18
View File
@@ -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);
}
}
+2 -1
View File
@@ -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',
+21 -2
View File
@@ -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');
+22 -4
View File
@@ -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();
}
}
+21 -2
View File
@@ -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');
+21 -2
View File
@@ -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');
+22 -2
View File
@@ -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');
+24 -2
View File
@@ -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');
+24 -2
View File
@@ -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');
+24 -2
View File
@@ -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');
+23 -2
View File
@@ -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');
+13 -1
View File
@@ -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
{
+16 -2
View File
@@ -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
View File
@@ -19,7 +19,10 @@ class Tag extends BaseModel
{
use HasSafeStringAttribute;
protected $guarded = [];
protected $fillable = [
'name',
'team_id',
];
protected function customizeName($value)
{
+28 -14
View File
@@ -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()
+4 -2
View File
@@ -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
View File
@@ -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,