mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 20:15:45 +00:00
Merge remote-tracking branch 'origin/next' into feat/railpack
This commit is contained in:
commit
207002dbb7
45 files changed
+1572
-272
No files matched your search
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class GetProxyConfiguration
|
||||
{
|
||||
@@ -24,6 +26,17 @@ class GetProxyConfiguration
|
||||
// Primary source: database
|
||||
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
|
||||
|
||||
// Validate stored config matches current proxy type
|
||||
if (! empty(trim($proxy_configuration ?? ''))) {
|
||||
if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) {
|
||||
Log::warning('Stored proxy config does not match current proxy type, will regenerate', [
|
||||
'server_id' => $server->id,
|
||||
'proxy_type' => $proxyType,
|
||||
]);
|
||||
$proxy_configuration = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill: existing servers may not have DB config yet — read from disk once
|
||||
if (empty(trim($proxy_configuration ?? ''))) {
|
||||
$proxy_configuration = $this->backfillFromDisk($server);
|
||||
@@ -55,6 +68,29 @@ class GetProxyConfiguration
|
||||
return $proxy_configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the stored docker-compose YAML contains the expected service
|
||||
* for the server's current proxy type. Returns false if the config belongs
|
||||
* to a different proxy type (e.g. Traefik config on a CADDY server).
|
||||
*/
|
||||
private function configMatchesProxyType(string $proxyType, string $configuration): bool
|
||||
{
|
||||
try {
|
||||
$yaml = Yaml::parse($configuration);
|
||||
$services = data_get($yaml, 'services', []);
|
||||
|
||||
return match ($proxyType) {
|
||||
ProxyTypes::TRAEFIK->value => isset($services['traefik']),
|
||||
ProxyTypes::CADDY->value => isset($services['caddy']),
|
||||
ProxyTypes::NGINX->value => isset($services['nginx']),
|
||||
default => true,
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
// If YAML is unparseable, don't block — let the existing flow handle it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill: read config from disk for servers that predate DB storage.
|
||||
* Stores the result in the database so future reads skip SSH entirely.
|
||||
|
||||
@@ -11,11 +11,8 @@ class InstallDocker
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private string $dockerVersion;
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
$this->dockerVersion = config('constants.docker.minimum_required_version');
|
||||
$supported_os_type = $server->validateOS();
|
||||
if (! $supported_os_type) {
|
||||
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
|
||||
@@ -118,7 +115,7 @@ class InstallDocker
|
||||
|
||||
private function getDebianDockerInstallCommand(): string
|
||||
{
|
||||
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
|
||||
return 'curl -fsSL https://get.docker.com | sh || ('.
|
||||
'. /etc/os-release && '.
|
||||
'install -m 0755 -d /etc/apt/keyrings && '.
|
||||
'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '.
|
||||
@@ -131,7 +128,7 @@ class InstallDocker
|
||||
|
||||
private function getRhelDockerInstallCommand(): string
|
||||
{
|
||||
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
|
||||
return 'curl -fsSL https://get.docker.com | sh || ('.
|
||||
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
|
||||
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
|
||||
'systemctl start docker && '.
|
||||
@@ -141,7 +138,7 @@ class InstallDocker
|
||||
|
||||
private function getSuseDockerInstallCommand(): string
|
||||
{
|
||||
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
|
||||
return 'curl -fsSL https://get.docker.com | sh || ('.
|
||||
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
|
||||
'zypper refresh && '.
|
||||
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
|
||||
@@ -152,10 +149,6 @@ class InstallDocker
|
||||
|
||||
private function getArchDockerInstallCommand(): string
|
||||
{
|
||||
// Use -Syu to perform full system upgrade before installing Docker
|
||||
// Partial upgrades (-Sy without -u) are discouraged on Arch Linux
|
||||
// as they can lead to broken dependencies and system instability
|
||||
// Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
|
||||
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
|
||||
'systemctl enable docker.service && '.
|
||||
'systemctl start docker.service';
|
||||
@@ -163,6 +156,6 @@ class InstallDocker
|
||||
|
||||
private function getGenericDockerInstallCommand(): string
|
||||
{
|
||||
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
|
||||
return 'curl -fsSL https://get.docker.com | sh';
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,162 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
|
||||
*/
|
||||
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
|
||||
{
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$this->info("Syncing $envLabel files to GitHub repository...");
|
||||
try {
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
|
||||
$branchName = 'update-files-'.$timestamp;
|
||||
|
||||
// Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy each file to its target path in the CDN repo
|
||||
$copiedFiles = [];
|
||||
foreach ($files as $sourceFile => $targetPath) {
|
||||
if (! file_exists($sourceFile)) {
|
||||
$this->warn("Source file not found, skipping: $sourceFile");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$destPath = "$tmpDir/$targetPath";
|
||||
$destDir = dirname($destPath);
|
||||
|
||||
if (! is_dir($destDir)) {
|
||||
if (! mkdir($destDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $destDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (copy($sourceFile, $destPath) === false) {
|
||||
$this->error("Failed to copy $sourceFile to $destPath");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$copiedFiles[] = $targetPath;
|
||||
$this->info("Copied: $targetPath");
|
||||
}
|
||||
|
||||
if (empty($copiedFiles)) {
|
||||
$this->warn('No files were copied. Nothing to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stage all copied files
|
||||
$this->info('Staging changes...');
|
||||
$output = [];
|
||||
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
|
||||
exec($stageCmd, $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$this->info('All files are already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Commit changes
|
||||
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
|
||||
$fileList = implode("\n- ", $copiedFiles);
|
||||
$prBody = "Automated update of $envLabel files:\n- $fileList";
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$output = [];
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Pull request created successfully!');
|
||||
if (! empty($output)) {
|
||||
$this->info('PR URL: '.implode("\n", $output));
|
||||
}
|
||||
$this->info('Files synced: '.count($copiedFiles));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error syncing files to GitHub: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync versions.json to GitHub repository via PR
|
||||
*/
|
||||
@@ -581,11 +737,130 @@ class SyncBunny extends Command
|
||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||
}
|
||||
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
|
||||
$this->newLine();
|
||||
|
||||
// Build file mapping for diff
|
||||
if ($nightly) {
|
||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||
$fileMapping = [
|
||||
$compose_file_location => 'docker/nightly/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/nightly/.env.production',
|
||||
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
|
||||
$install_script_location => 'scripts/nightly/install.sh',
|
||||
];
|
||||
} else {
|
||||
$this->info('About to sync files PRODUCTION (docker-compose.yml, docker-compose.prod.yml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||
$fileMapping = [
|
||||
$compose_file_location => 'docker/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/.env.production',
|
||||
$upgrade_script_location => 'scripts/upgrade.sh',
|
||||
$install_script_location => 'scripts/install.sh',
|
||||
];
|
||||
}
|
||||
|
||||
// BunnyCDN file mapping (local file => CDN URL path)
|
||||
$bunnyFileMapping = [
|
||||
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
|
||||
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
|
||||
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
|
||||
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
|
||||
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
|
||||
];
|
||||
|
||||
$diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time();
|
||||
@mkdir($diffTmpDir, 0755, true);
|
||||
$hasChanges = false;
|
||||
|
||||
// Diff against BunnyCDN
|
||||
$this->info('Fetching files from BunnyCDN to compare...');
|
||||
foreach ($bunnyFileMapping as $localFile => $cdnUrl) {
|
||||
if (! file_exists($localFile)) {
|
||||
$this->warn('Local file not found: '.$localFile);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = basename($cdnUrl);
|
||||
$remoteTmp = "$diffTmpDir/bunny-$fileName";
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get($cdnUrl);
|
||||
if ($response->successful()) {
|
||||
file_put_contents($remoteTmp, $response->body());
|
||||
$diffOutput = [];
|
||||
exec('diff -u '.escapeshellarg($remoteTmp).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
|
||||
if ($diffCode !== 0) {
|
||||
$hasChanges = true;
|
||||
$this->newLine();
|
||||
$this->info("--- BunnyCDN: $bunny_cdn_path/$fileName");
|
||||
$this->info("+++ Local: $fileName");
|
||||
foreach ($diffOutput as $line) {
|
||||
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
|
||||
continue;
|
||||
}
|
||||
$this->line($line);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->info("NEW on BunnyCDN: $bunny_cdn_path/$fileName (HTTP {$response->status()})");
|
||||
$hasChanges = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Could not fetch $cdnUrl: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Diff against GitHub coolify-cdn repo
|
||||
$this->newLine();
|
||||
$this->info('Fetching coolify-cdn repo to compare...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
|
||||
|
||||
if ($returnCode === 0) {
|
||||
foreach ($fileMapping as $localFile => $cdnPath) {
|
||||
$remotePath = "$diffTmpDir/repo/$cdnPath";
|
||||
if (! file_exists($localFile)) {
|
||||
continue;
|
||||
}
|
||||
if (! file_exists($remotePath)) {
|
||||
$this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
|
||||
$hasChanges = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$diffOutput = [];
|
||||
exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
|
||||
if ($diffCode !== 0) {
|
||||
$hasChanges = true;
|
||||
$this->newLine();
|
||||
$this->info("--- GitHub: $cdnPath");
|
||||
$this->info("+++ Local: $cdnPath");
|
||||
foreach ($diffOutput as $line) {
|
||||
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
|
||||
continue;
|
||||
}
|
||||
$this->line($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->warn('Could not fetch coolify-cdn repo for diff.');
|
||||
}
|
||||
|
||||
exec('rm -rf '.escapeshellarg($diffTmpDir));
|
||||
|
||||
if (! $hasChanges) {
|
||||
$this->newLine();
|
||||
$this->info('No differences found. All files are already up to date.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$confirmed = confirm('Are you sure you want to sync?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
@@ -692,7 +967,34 @@ class SyncBunny extends Command
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
|
||||
]);
|
||||
$this->info('All files uploaded & purged...');
|
||||
$this->info('All files uploaded & purged to BunnyCDN.');
|
||||
$this->newLine();
|
||||
|
||||
// Sync files to GitHub CDN repository via PR
|
||||
$this->info('Creating GitHub PR for coolify-cdn repository...');
|
||||
if ($nightly) {
|
||||
$files = [
|
||||
$compose_file_location => 'docker/nightly/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/nightly/.env.production',
|
||||
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
|
||||
$install_script_location => 'scripts/nightly/install.sh',
|
||||
];
|
||||
} else {
|
||||
$files = [
|
||||
$compose_file_location => 'docker/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/.env.production',
|
||||
$upgrade_script_location => 'scripts/upgrade.sh',
|
||||
$install_script_location => 'scripts/install.sh',
|
||||
];
|
||||
}
|
||||
|
||||
$githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
|
||||
$this->newLine();
|
||||
$this->info('=== Summary ===');
|
||||
$this->info('BunnyCDN sync: Complete');
|
||||
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
@@ -586,7 +586,8 @@ class HetznerController extends Controller
|
||||
}
|
||||
|
||||
// Check server limit
|
||||
if (Team::serverLimitReached()) {
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -290,7 +290,11 @@ class ServersController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->get('uuid');
|
||||
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (is_null($server)) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$uuid = $request->query('uuid');
|
||||
if ($uuid) {
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
if (! $application) {
|
||||
@@ -301,7 +305,9 @@ class ServersController extends Controller
|
||||
}
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$domains = collect();
|
||||
$applications = $projects->pluck('applications')->flatten();
|
||||
$applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) {
|
||||
return $application->destination?->server?->id === $server->id;
|
||||
});
|
||||
$settings = instanceSettings();
|
||||
if ($applications->count() > 0) {
|
||||
foreach ($applications as $application) {
|
||||
@@ -341,7 +347,9 @@ class ServersController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
$services = $projects->pluck('services')->flatten();
|
||||
$services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) {
|
||||
return $service->server_id === $server->id;
|
||||
});
|
||||
if ($services->count() > 0) {
|
||||
foreach ($services as $service) {
|
||||
$service_applications = $service->applications;
|
||||
@@ -354,7 +362,8 @@ class ServersController extends Controller
|
||||
})->filter(function (Stringable $fqdn) {
|
||||
return $fqdn->isNotEmpty();
|
||||
});
|
||||
if ($ip === 'host.docker.internal') {
|
||||
$serviceIp = $server->ip;
|
||||
if ($serviceIp === 'host.docker.internal') {
|
||||
if ($settings->public_ipv4) {
|
||||
$domains->push([
|
||||
'domain' => $fqdn,
|
||||
@@ -370,13 +379,13 @@ class ServersController extends Controller
|
||||
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
|
||||
$domains->push([
|
||||
'domain' => $fqdn,
|
||||
'ip' => $ip,
|
||||
'ip' => $serviceIp,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$domains->push([
|
||||
'domain' => $fqdn,
|
||||
'ip' => $ip,
|
||||
'ip' => $serviceIp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1370,6 +1370,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
|
||||
// Fall back to production env vars for keys not overridden by preview vars,
|
||||
// but only when preview vars are configured. This ensures variables like
|
||||
// DB_PASSWORD that are only set for production will be available in the
|
||||
// preview .env file (fixing ${VAR} interpolation in docker-compose YAML),
|
||||
// while avoiding leaking production values when previews aren't configured.
|
||||
if ($runtime_environment_variables_preview->isNotEmpty()) {
|
||||
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
|
||||
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
|
||||
return $env->is_runtime && ! in_array($env->key, $previewKeys);
|
||||
});
|
||||
foreach ($fallback_production_vars as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add PORT if not exists, use the first port as default
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
|
||||
|
||||
@@ -399,7 +399,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
's3_uploaded' => null,
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
|
||||
try {
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
|
||||
} catch (\Throwable $notifyException) {
|
||||
Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'database' => $database,
|
||||
'error' => $notifyException->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -439,11 +447,20 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'local_storage_deleted' => $localStorageDeleted,
|
||||
]);
|
||||
|
||||
// Send appropriate notification
|
||||
if ($s3UploadError) {
|
||||
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
|
||||
} else {
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||
// Send appropriate notification (wrapped in try-catch so notification
|
||||
// failures never affect backup status — see GitHub issue #9088)
|
||||
try {
|
||||
if ($s3UploadError) {
|
||||
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
|
||||
} else {
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'database' => $database,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,20 +727,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
||||
|
||||
if ($log) {
|
||||
$log->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'size' => 0,
|
||||
'filename' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
// Don't overwrite a successful backup status — a post-backup error
|
||||
// (e.g. notification failure) should not retroactively mark the backup
|
||||
// as failed (see GitHub issue #9088)
|
||||
if ($log->status !== 'success') {
|
||||
$log->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'size' => 0,
|
||||
'filename' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
if ($this->team) {
|
||||
// Notify team about permanent failure (only if backup didn't already succeed)
|
||||
if ($this->team && $log?->status !== 'success') {
|
||||
$databaseName = $log?->database_name ?? 'unknown';
|
||||
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
|
||||
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
|
||||
try {
|
||||
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,25 +73,15 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
}
|
||||
$subscription = Subscription::where('team_id', $teamId)->first();
|
||||
if ($subscription) {
|
||||
// send_internal_notification('Old subscription activated for team: '.$teamId);
|
||||
$subscription->update([
|
||||
Subscription::updateOrCreate(
|
||||
['team_id' => $teamId],
|
||||
[
|
||||
'stripe_subscription_id' => $subscriptionId,
|
||||
'stripe_customer_id' => $customerId,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
} else {
|
||||
// send_internal_notification('New subscription for team: '.$teamId);
|
||||
Subscription::create([
|
||||
'team_id' => $teamId,
|
||||
'stripe_subscription_id' => $subscriptionId,
|
||||
'stripe_customer_id' => $customerId,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
}
|
||||
]
|
||||
);
|
||||
break;
|
||||
case 'invoice.paid':
|
||||
$customerId = data_get($data, 'customer');
|
||||
@@ -227,18 +217,14 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
}
|
||||
$subscription = Subscription::where('team_id', $teamId)->first();
|
||||
if ($subscription) {
|
||||
// send_internal_notification("Subscription already exists for team: {$teamId}");
|
||||
throw new \RuntimeException("Subscription already exists for team: {$teamId}");
|
||||
} else {
|
||||
Subscription::create([
|
||||
'team_id' => $teamId,
|
||||
Subscription::updateOrCreate(
|
||||
['team_id' => $teamId],
|
||||
[
|
||||
'stripe_subscription_id' => $subscriptionId,
|
||||
'stripe_customer_id' => $customerId,
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
}
|
||||
]
|
||||
);
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
$teamId = data_get($data, 'metadata.team_id');
|
||||
@@ -254,20 +240,19 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
if ($status === 'incomplete_expired') {
|
||||
// send_internal_notification('Subscription incomplete expired');
|
||||
throw new \RuntimeException('Subscription incomplete expired');
|
||||
}
|
||||
if ($teamId) {
|
||||
$subscription = Subscription::create([
|
||||
'team_id' => $teamId,
|
||||
if (! $teamId) {
|
||||
throw new \RuntimeException('No subscription and team id found');
|
||||
}
|
||||
$subscription = Subscription::firstOrCreate(
|
||||
['team_id' => $teamId],
|
||||
[
|
||||
'stripe_subscription_id' => $subscriptionId,
|
||||
'stripe_customer_id' => $customerId,
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
} else {
|
||||
// send_internal_notification('No subscription and team id found');
|
||||
throw new \RuntimeException('No subscription and team id found');
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
$cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end');
|
||||
$feedback = data_get($data, 'cancellation_details.feedback');
|
||||
|
||||
@@ -55,7 +55,18 @@ class ActivityMonitor extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activity = Activity::find($this->activityId);
|
||||
$activity = Activity::find($this->activityId);
|
||||
|
||||
if ($activity) {
|
||||
$teamId = data_get($activity, 'properties.team_id');
|
||||
if ($teamId && $teamId !== currentTeam()?->id) {
|
||||
$this->activity = null;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->activity = $activity;
|
||||
}
|
||||
|
||||
public function updatedActivityId($value)
|
||||
|
||||
@@ -11,6 +11,12 @@ class PricingPlans extends Component
|
||||
{
|
||||
public function subscribeStripe($type)
|
||||
{
|
||||
if (currentTeam()->subscription?->stripe_invoice_paid) {
|
||||
$this->dispatch('error', 'Team already has an active subscription.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Stripe::setApiKey(config('subscription.stripe_api_key'));
|
||||
|
||||
$priceId = match ($type) {
|
||||
|
||||
@@ -32,6 +32,11 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class EnvironmentVariable extends BaseModel
|
||||
{
|
||||
protected $attributes = [
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
// Core identification
|
||||
'key',
|
||||
|
||||
@@ -1471,6 +1471,9 @@ $schema://$host {
|
||||
if ($validProxyTypes->contains(str($proxyType)->lower())) {
|
||||
$this->proxy->set('type', str($proxyType)->upper());
|
||||
$this->proxy->set('status', 'exited');
|
||||
$this->proxy->set('last_saved_proxy_configuration', null);
|
||||
$this->proxy->set('last_saved_settings', null);
|
||||
$this->proxy->set('last_applied_settings', null);
|
||||
$this->save();
|
||||
if ($this->proxySet()) {
|
||||
if ($async) {
|
||||
|
||||
+14
-7
@@ -89,10 +89,13 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
});
|
||||
}
|
||||
|
||||
public static function serverLimitReached()
|
||||
public static function serverLimitReached(?Team $team = null)
|
||||
{
|
||||
$serverLimit = Team::serverLimit();
|
||||
$team = currentTeam();
|
||||
$team = $team ?? currentTeam();
|
||||
if (! $team) {
|
||||
return true;
|
||||
}
|
||||
$serverLimit = Team::serverLimit($team);
|
||||
$servers = $team->servers->count();
|
||||
|
||||
return $servers >= $serverLimit;
|
||||
@@ -109,19 +112,23 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
|
||||
public function serverOverflow()
|
||||
{
|
||||
if ($this->serverLimit() < $this->servers->count()) {
|
||||
if (Team::serverLimit($this) < $this->servers->count()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function serverLimit()
|
||||
public static function serverLimit(?Team $team = null)
|
||||
{
|
||||
if (currentTeam()->id === 0 && isDev()) {
|
||||
$team = $team ?? currentTeam();
|
||||
if (! $team) {
|
||||
return 0;
|
||||
}
|
||||
if ($team->id === 0 && isDev()) {
|
||||
return 9999999;
|
||||
}
|
||||
$team = Team::find(currentTeam()->id);
|
||||
$team = Team::find($team->id);
|
||||
if (! $team) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -62,12 +62,15 @@ class ValidHostname implements ValidationRule
|
||||
// Ignore errors when facades are not available (e.g., in unit tests)
|
||||
}
|
||||
|
||||
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive)
|
||||
$hostname = strtolower($hostname);
|
||||
|
||||
// Additional validation: hostname should not start or end with a dot
|
||||
if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) {
|
||||
$fail('The :attribute cannot start or end with a dot.');
|
||||
@@ -100,9 +103,9 @@ class ValidHostname implements ValidationRule
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if label contains only valid characters (lowercase letters, digits, hyphens)
|
||||
// Check if label contains only valid characters (letters, digits, hyphens)
|
||||
if (! preg_match('/^[a-z0-9-]+$/', $label)) {
|
||||
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class ValidationPatterns
|
||||
/**
|
||||
* Pattern for names excluding all dangerous characters
|
||||
*/
|
||||
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
|
||||
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u';
|
||||
|
||||
/**
|
||||
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
|
||||
@@ -96,7 +96,7 @@ class ValidationPatterns
|
||||
public static function nameMessages(): array
|
||||
{
|
||||
return [
|
||||
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &',
|
||||
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +',
|
||||
'name.min' => 'The name must be at least :min characters.',
|
||||
'name.max' => 'The name may not be greater than :max characters.',
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user