Merge branch 'v4.x' of github.com:thevinodpatidar/coolify into v4.x

This commit is contained in:
thevinodpatidar 2025-10-31 11:02:16 +05:30
commit 622966b9d8
13 changed files with 954 additions and 29 deletions

View file

@ -0,0 +1,791 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Yaml\Yaml;
class UpdateServiceVersions extends Command
{
protected $signature = 'services:update-versions
{--service= : Update specific service template}
{--dry-run : Show what would be updated without making changes}
{--registry= : Filter by registry (dockerhub, ghcr, quay, codeberg)}';
protected $description = 'Update service template files with latest Docker image versions from registries';
protected array $stats = [
'total' => 0,
'updated' => 0,
'failed' => 0,
'skipped' => 0,
];
protected array $registryCache = [];
protected array $majorVersionUpdates = [];
public function handle(): int
{
$this->info('Starting service version update...');
$templateFiles = $this->getTemplateFiles();
$this->stats['total'] = count($templateFiles);
foreach ($templateFiles as $file) {
$this->processTemplate($file);
}
$this->newLine();
$this->displayStats();
return self::SUCCESS;
}
protected function getTemplateFiles(): array
{
$pattern = base_path('templates/compose/*.yaml');
$files = glob($pattern);
if ($service = $this->option('service')) {
$files = array_filter($files, fn ($file) => basename($file) === "$service.yaml");
}
return $files;
}
protected function processTemplate(string $filePath): void
{
$filename = basename($filePath);
$this->info("Processing: {$filename}");
try {
$content = file_get_contents($filePath);
$yaml = Yaml::parse($content);
if (! isset($yaml['services'])) {
$this->warn(" No services found in {$filename}");
$this->stats['skipped']++;
return;
}
$updated = false;
$updatedYaml = $yaml;
foreach ($yaml['services'] as $serviceName => $serviceConfig) {
if (! isset($serviceConfig['image'])) {
continue;
}
$currentImage = $serviceConfig['image'];
// Check if using 'latest' tag and log for manual review
if (str_contains($currentImage, ':latest')) {
$registryUrl = $this->getRegistryUrl($currentImage);
$this->warn(" {$serviceName}: {$currentImage} (using 'latest' tag)");
if ($registryUrl) {
$this->line(" → Manual review: {$registryUrl}");
}
}
$latestVersion = $this->getLatestVersion($currentImage);
if ($latestVersion && $latestVersion !== $currentImage) {
$this->line(" {$serviceName}: {$currentImage}{$latestVersion}");
$updatedYaml['services'][$serviceName]['image'] = $latestVersion;
$updated = true;
} else {
$this->line(" {$serviceName}: {$currentImage} (up to date)");
}
}
if ($updated) {
if (! $this->option('dry-run')) {
$this->updateYamlFile($filePath, $content, $updatedYaml);
$this->stats['updated']++;
} else {
$this->warn(' [DRY RUN] Would update this file');
$this->stats['updated']++;
}
} else {
$this->stats['skipped']++;
}
} catch (\Throwable $e) {
$this->error(" Failed: {$e->getMessage()}");
$this->stats['failed']++;
}
$this->newLine();
}
protected function getLatestVersion(string $image): ?string
{
// Parse the image string
[$repository, $currentTag] = $this->parseImage($image);
// Determine registry and fetch latest version
$result = null;
if (str_starts_with($repository, 'ghcr.io/')) {
$result = $this->getGhcrLatestVersion($repository, $currentTag);
} elseif (str_starts_with($repository, 'quay.io/')) {
$result = $this->getQuayLatestVersion($repository, $currentTag);
} elseif (str_starts_with($repository, 'codeberg.org/')) {
$result = $this->getCodebergLatestVersion($repository, $currentTag);
} elseif (str_starts_with($repository, 'lscr.io/')) {
$result = $this->getDockerHubLatestVersion($repository, $currentTag);
} elseif ($this->isCustomRegistry($repository)) {
// Custom registries - skip for now, log warning
$this->warn(" Skipping custom registry: {$repository}");
$result = null;
} else {
// DockerHub (default registry - no prefix or docker.io/index.docker.io)
$result = $this->getDockerHubLatestVersion($repository, $currentTag);
}
return $result;
}
protected function isCustomRegistry(string $repository): bool
{
// List of custom/private registries that we can't query
$customRegistries = [
'docker.elastic.co/',
'docker.n8n.io/',
'docker.flipt.io/',
'docker.getoutline.com/',
'cr.weaviate.io/',
'downloads.unstructured.io/',
'budibase.docker.scarf.sh/',
'calcom.docker.scarf.sh/',
'code.forgejo.org/',
'registry.supertokens.io/',
'registry.rocket.chat/',
'nabo.codimd.dev/',
'gcr.io/',
];
foreach ($customRegistries as $registry) {
if (str_starts_with($repository, $registry)) {
return true;
}
}
return false;
}
protected function getRegistryUrl(string $image): ?string
{
[$repository] = $this->parseImage($image);
// GitHub Container Registry
if (str_starts_with($repository, 'ghcr.io/')) {
$parts = explode('/', str_replace('ghcr.io/', '', $repository));
if (count($parts) >= 2) {
return "https://github.com/{$parts[0]}/{$parts[1]}/pkgs/container/{$parts[1]}";
}
}
// Quay.io
if (str_starts_with($repository, 'quay.io/')) {
$repo = str_replace('quay.io/', '', $repository);
return "https://quay.io/repository/{$repo}?tab=tags";
}
// Codeberg
if (str_starts_with($repository, 'codeberg.org/')) {
$parts = explode('/', str_replace('codeberg.org/', '', $repository));
if (count($parts) >= 2) {
return "https://codeberg.org/{$parts[0]}/-/packages/container/{$parts[1]}";
}
}
// Docker Hub
$cleanRepo = str_replace(['index.docker.io/', 'docker.io/', 'lscr.io/'], '', $repository);
if (! str_contains($cleanRepo, '/')) {
// Official image
return "https://hub.docker.com/_/{$cleanRepo}/tags";
} else {
// User/org image
return "https://hub.docker.com/r/{$cleanRepo}/tags";
}
}
protected function parseImage(string $image): array
{
if (str_contains($image, ':')) {
[$repo, $tag] = explode(':', $image, 2);
} else {
$repo = $image;
$tag = 'latest';
}
// Handle variables in tags
if (str_contains($tag, '$')) {
$tag = 'latest'; // Default to latest for variable tags
}
return [$repo, $tag];
}
protected function getDockerHubLatestVersion(string $repository, string $currentTag): ?string
{
try {
// Check if we've already fetched tags for this repository
if (! isset($this->registryCache[$repository.'_tags'])) {
// Remove various registry prefixes
$cleanRepo = $repository;
$cleanRepo = str_replace('index.docker.io/', '', $cleanRepo);
$cleanRepo = str_replace('docker.io/', '', $cleanRepo);
$cleanRepo = str_replace('lscr.io/', '', $cleanRepo);
// For official images (no /) add library prefix
if (! str_contains($cleanRepo, '/')) {
$cleanRepo = "library/{$cleanRepo}";
}
$url = "https://hub.docker.com/v2/repositories/{$cleanRepo}/tags";
$response = Http::timeout(10)->get($url, [
'page_size' => 100,
'ordering' => 'last_updated',
]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$tags = $data['results'] ?? [];
// Cache the tags for this repository
$this->registryCache[$repository.'_tags'] = $tags;
} else {
$this->line(" [cached] Using cached tags for {$repository}");
$tags = $this->registryCache[$repository.'_tags'];
}
// Find the best matching tag
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" DockerHub API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function findLatestTagDigest(array $tags, string $targetTag = 'latest'): ?string
{
// Find the digest/sha for the target tag (usually 'latest')
foreach ($tags as $tag) {
if ($tag['name'] === $targetTag) {
return $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
}
}
return null;
}
protected function findVersionTagsForDigest(array $tags, string $digest): array
{
// Find all semantic version tags that share the same digest
$versionTags = [];
foreach ($tags as $tag) {
$tagDigest = $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
if ($tagDigest === $digest) {
$tagName = $tag['name'];
// Only include semantic version tags
if (preg_match('/^\d+\.\d+(\.\d+)?$/', $tagName)) {
$versionTags[] = $tagName;
}
}
}
return $versionTags;
}
protected function getGhcrLatestVersion(string $repository, string $currentTag): ?string
{
try {
// GHCR doesn't have a public API for listing tags without auth
// We'll try to fetch the package metadata via GitHub API
$parts = explode('/', str_replace('ghcr.io/', '', $repository));
if (count($parts) < 2) {
return null;
}
$owner = $parts[0];
$package = $parts[1];
// Try GitHub Container Registry API
$url = "https://api.github.com/users/{$owner}/packages/container/{$package}/versions";
$response = Http::timeout(10)
->withHeaders([
'Accept' => 'application/vnd.github.v3+json',
])
->get($url, ['per_page' => 100]);
if (! $response->successful()) {
// Most GHCR packages require authentication
if ($currentTag === 'latest') {
$this->warn(' ⚠ GHCR requires authentication - manual review needed');
}
return null;
}
$versions = $response->json();
$tags = [];
// Build tags array with digest information
foreach ($versions as $version) {
$digest = $version['name'] ?? null; // This is the SHA digest
if (isset($version['metadata']['container']['tags'])) {
foreach ($version['metadata']['container']['tags'] as $tag) {
$tags[] = [
'name' => $tag,
'digest' => $digest,
];
}
}
}
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" GHCR API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function getQuayLatestVersion(string $repository, string $currentTag): ?string
{
try {
// Check if we've already fetched tags for this repository
if (! isset($this->registryCache[$repository.'_tags'])) {
$cleanRepo = str_replace('quay.io/', '', $repository);
$url = "https://quay.io/api/v1/repository/{$cleanRepo}/tag/";
$response = Http::timeout(10)->get($url, ['limit' => 100]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$tags = array_map(fn ($tag) => ['name' => $tag['name']], $data['tags'] ?? []);
// Cache the tags for this repository
$this->registryCache[$repository.'_tags'] = $tags;
} else {
$this->line(" [cached] Using cached tags for {$repository}");
$tags = $this->registryCache[$repository.'_tags'];
}
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" Quay API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function getCodebergLatestVersion(string $repository, string $currentTag): ?string
{
try {
// Check if we've already fetched tags for this repository
if (! isset($this->registryCache[$repository.'_tags'])) {
// Codeberg uses Forgejo/Gitea, which has a container registry API
$cleanRepo = str_replace('codeberg.org/', '', $repository);
$parts = explode('/', $cleanRepo);
if (count($parts) < 2) {
return null;
}
$owner = $parts[0];
$package = $parts[1];
// Codeberg API endpoint for packages
$url = "https://codeberg.org/api/packages/{$owner}/container/{$package}";
$response = Http::timeout(10)->get($url);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$tags = [];
if (isset($data['versions'])) {
foreach ($data['versions'] as $version) {
if (isset($version['name'])) {
$tags[] = ['name' => $version['name']];
}
}
}
// Cache the tags for this repository
$this->registryCache[$repository.'_tags'] = $tags;
} else {
$this->line(" [cached] Using cached tags for {$repository}");
$tags = $this->registryCache[$repository.'_tags'];
}
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" Codeberg API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function findBestTag(array $tags, string $currentTag, string $repository): ?string
{
if (empty($tags)) {
return null;
}
// If current tag is 'latest', find what version it actually points to
if ($currentTag === 'latest') {
// First, try to find the digest for 'latest' tag
$latestDigest = $this->findLatestTagDigest($tags, 'latest');
if ($latestDigest) {
// Find all semantic version tags that share the same digest
$versionTags = $this->findVersionTagsForDigest($tags, $latestDigest);
if (! empty($versionTags)) {
// Prefer shorter version tags (1.8 over 1.8.1)
$bestVersion = $this->preferShorterVersion($versionTags);
$this->info(" ✓ Found 'latest' points to: {$bestVersion}");
return $repository.':'.$bestVersion;
}
}
// Fallback: get the latest semantic version available (prefer shorter)
$semverTags = $this->filterSemanticVersionTags($tags);
if (! empty($semverTags)) {
$bestVersion = $this->preferShorterVersion($semverTags);
return $repository.':'.$bestVersion;
}
// If no semantic versions found, keep 'latest'
return null;
}
// Check for major version updates for reporting
$this->checkForMajorVersionUpdate($tags, $currentTag, $repository);
// If current tag is a major version (e.g., "8", "5", "16")
if (preg_match('/^\d+$/', $currentTag)) {
$majorVersion = (int) $currentTag;
$matchingTags = array_filter($tags, function ($tag) use ($majorVersion) {
$name = $tag['name'];
// Match tags that start with the major version
return preg_match("/^{$majorVersion}(\.\d+)?(\.\d+)?$/", $name);
});
if (! empty($matchingTags)) {
$versions = array_column($matchingTags, 'name');
$bestVersion = $this->preferShorterVersion($versions);
if ($bestVersion !== $currentTag) {
return $repository.':'.$bestVersion;
}
}
}
// If current tag is date-based version (e.g., "2025.06.02-sha-xxx")
if (preg_match('/^\d{4}\.\d{2}\.\d{2}/', $currentTag)) {
// Get all date-based tags
$dateTags = array_filter($tags, function ($tag) {
return preg_match('/^\d{4}\.\d{2}\.\d{2}/', $tag['name']);
});
if (! empty($dateTags)) {
$versions = array_column($dateTags, 'name');
$sorted = $this->sortSemanticVersions($versions);
$latestDate = $sorted[0];
// Compare dates
if ($latestDate !== $currentTag) {
return $repository.':'.$latestDate;
}
}
return null;
}
// If current tag is semantic version (e.g., "1.7.4", "8.0")
if (preg_match('/^\d+\.\d+(\.\d+)?$/', $currentTag)) {
$parts = explode('.', $currentTag);
$majorMinor = $parts[0].'.'.$parts[1];
$matchingTags = array_filter($tags, function ($tag) use ($majorMinor) {
$name = $tag['name'];
return str_starts_with($name, $majorMinor);
});
if (! empty($matchingTags)) {
$versions = array_column($matchingTags, 'name');
$bestVersion = $this->preferShorterVersion($versions);
if (version_compare($bestVersion, $currentTag, '>') || version_compare($bestVersion, $currentTag, '=')) {
// Only update if it's newer or if we can simplify (1.8.1 -> 1.8)
if ($bestVersion !== $currentTag) {
return $repository.':'.$bestVersion;
}
}
}
}
// If current tag is a named version (e.g., "stable")
if (in_array($currentTag, ['stable', 'lts', 'edge'])) {
// Check if the same tag exists in the list (it's up to date)
$exists = array_filter($tags, fn ($tag) => $tag['name'] === $currentTag);
if (! empty($exists)) {
return null; // Tag exists and is current
}
}
return null;
}
protected function filterSemanticVersionTags(array $tags): array
{
$semverTags = array_filter($tags, function ($tag) {
$name = $tag['name'];
// Accept semantic versions (1.2.3, v1.2.3)
if (preg_match('/^v?\d+\.\d+(\.\d+)?(\.\d+)?$/', $name)) {
// Exclude versions with suffixes like -rc, -beta, -alpha
if (preg_match('/-(rc|beta|alpha|dev|test|pre|snapshot)/i', $name)) {
return false;
}
return true;
}
// Accept date-based versions (2025.06.02, 2025.10.0, 2025.06.02-sha-xxx, RELEASE.2025-10-15T17-29-55Z)
if (preg_match('/^\d{4}\.\d{2}\.(\d{2}|\d)/', $name) || preg_match('/^RELEASE\.\d{4}-\d{2}-\d{2}/', $name)) {
return true;
}
return false;
});
return $this->sortSemanticVersions(array_column($semverTags, 'name'));
}
protected function sortSemanticVersions(array $versions): array
{
usort($versions, function ($a, $b) {
// Check if these are date-based versions (YYYY.MM.DD or YYYY.MM.D format)
$isDateA = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $a, $matchesA);
$isDateB = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $b, $matchesB);
if ($isDateA && $isDateB) {
// Both are date-based (YYYY.MM.DD), compare as dates
$dateA = $matchesA[1].$matchesA[2].str_pad($matchesA[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
$dateB = $matchesB[1].$matchesB[2].str_pad($matchesB[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
return strcmp($dateB, $dateA); // Descending order (newest first)
}
// Check if these are RELEASE date versions (RELEASE.YYYY-MM-DDTHH-MM-SSZ)
$isReleaseA = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $a, $matchesA);
$isReleaseB = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $b, $matchesB);
if ($isReleaseA && $isReleaseB) {
// Both are RELEASE format, compare as datetime
$dateTimeA = $matchesA[1].$matchesA[2].$matchesA[3].$matchesA[4].$matchesA[5].$matchesA[6]; // YYYYMMDDHHMMSS
$dateTimeB = $matchesB[1].$matchesB[2].$matchesB[3].$matchesB[4].$matchesB[5].$matchesB[6]; // YYYYMMDDHHMMSS
return strcmp($dateTimeB, $dateTimeA); // Descending order (newest first)
}
// Strip 'v' prefix for version comparison
$cleanA = ltrim($a, 'v');
$cleanB = ltrim($b, 'v');
// Fall back to semantic version comparison
return version_compare($cleanB, $cleanA); // Descending order
});
return $versions;
}
protected function preferShorterVersion(array $versions): string
{
if (empty($versions)) {
return '';
}
// Sort by version (highest first)
$sorted = $this->sortSemanticVersions($versions);
$highest = $sorted[0];
// Parse the highest version
$parts = explode('.', $highest);
// Look for shorter versions that match
// Priority: major (8) > major.minor (8.0) > major.minor.patch (8.0.39)
// Try to find just major.minor (e.g., 1.8 instead of 1.8.1)
if (count($parts) === 3) {
$majorMinor = $parts[0].'.'.$parts[1];
if (in_array($majorMinor, $versions)) {
return $majorMinor;
}
}
// Try to find just major (e.g., 8 instead of 8.0.39)
if (count($parts) >= 2) {
$major = $parts[0];
if (in_array($major, $versions)) {
return $major;
}
}
// Return the highest version we found
return $highest;
}
protected function updateYamlFile(string $filePath, string $originalContent, array $updatedYaml): void
{
// Preserve comments and formatting by updating the YAML content
$lines = explode("\n", $originalContent);
$updatedLines = [];
$inServices = false;
$currentService = null;
foreach ($lines as $line) {
// Detect if we're in the services section
if (preg_match('/^services:/', $line)) {
$inServices = true;
$updatedLines[] = $line;
continue;
}
// Detect service name (allow hyphens and underscores)
if ($inServices && preg_match('/^ ([\w-]+):/', $line, $matches)) {
$currentService = $matches[1];
$updatedLines[] = $line;
continue;
}
// Update image line
if ($currentService && preg_match('/^(\s+)image:\s*(.+)$/', $line, $matches)) {
$indent = $matches[1];
$newImage = $updatedYaml['services'][$currentService]['image'] ?? $matches[2];
$updatedLines[] = "{$indent}image: {$newImage}";
continue;
}
// If we hit a non-indented line, we're out of services
if ($inServices && preg_match('/^\S/', $line) && ! preg_match('/^services:/', $line)) {
$inServices = false;
$currentService = null;
}
$updatedLines[] = $line;
}
file_put_contents($filePath, implode("\n", $updatedLines));
}
protected function checkForMajorVersionUpdate(array $tags, string $currentTag, string $repository): void
{
// Only check semantic versions
if (! preg_match('/^v?(\d+)\./', $currentTag, $currentMatches)) {
return;
}
$currentMajor = (int) $currentMatches[1];
// Get all semantic version tags
$semverTags = $this->filterSemanticVersionTags($tags);
// Find the highest major version available
$highestMajor = $currentMajor;
foreach ($semverTags as $version) {
if (preg_match('/^v?(\d+)\./', $version, $matches)) {
$major = (int) $matches[1];
if ($major > $highestMajor) {
$highestMajor = $major;
}
}
}
// If there's a higher major version available, record it
if ($highestMajor > $currentMajor) {
$this->majorVersionUpdates[] = [
'repository' => $repository,
'current' => $currentTag,
'current_major' => $currentMajor,
'available_major' => $highestMajor,
'registry_url' => $this->getRegistryUrl($repository.':'.$currentTag),
];
}
}
protected function displayStats(): void
{
$this->info('Summary:');
$this->table(
['Metric', 'Count'],
[
['Total Templates', $this->stats['total']],
['Updated', $this->stats['updated']],
['Skipped (up to date)', $this->stats['skipped']],
['Failed', $this->stats['failed']],
]
);
// Display major version updates if any
if (! empty($this->majorVersionUpdates)) {
$this->newLine();
$this->warn('⚠ Services with available MAJOR version updates:');
$this->newLine();
$tableData = [];
foreach ($this->majorVersionUpdates as $update) {
$tableData[] = [
$update['repository'],
"v{$update['current_major']}.x",
"v{$update['available_major']}.x",
$update['registry_url'],
];
}
$this->table(
['Repository', 'Current', 'Available', 'Registry URL'],
$tableData
);
$this->newLine();
$this->comment('💡 Major version updates may include breaking changes. Review before upgrading.');
}
}
}

View file

@ -30,6 +30,60 @@ public function loadTokens()
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
public function validateToken(int $tokenId)
{
try {
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('view', $token);
if ($token->provider === 'hetzner') {
$isValid = $this->validateHetznerToken($token->token);
if ($isValid) {
$this->dispatch('success', 'Hetzner token is valid.');
} else {
$this->dispatch('error', 'Hetzner token validation failed. Please check the token.');
}
} elseif ($token->provider === 'digitalocean') {
$isValid = $this->validateDigitalOceanToken($token->token);
if ($isValid) {
$this->dispatch('success', 'DigitalOcean token is valid.');
} else {
$this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
}
} else {
$this->dispatch('error', 'Unknown provider.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function validateHetznerToken(string $token): bool
{
try {
$response = \Illuminate\Support\Facades\Http::withToken($token)
->timeout(10)
->get('https://api.hetzner.cloud/v1/servers?per_page=1');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
private function validateDigitalOceanToken(string $token): bool
{
try {
$response = \Illuminate\Support\Facades\Http::withToken($token)
->timeout(10)
->get('https://api.digitalocean.com/v2/account');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
public function deleteToken(int $tokenId)
{
try {

View file

@ -561,7 +561,12 @@ public function submit()
$server->save();
if ($this->from_onboarding) {
// When in onboarding, use wire:navigate for proper modal handling
// Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([
'show_boarding' => false,
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
}

View file

@ -28,7 +28,20 @@ protected static function booted(): void
if ($applications_count > 0) {
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
}
$github_app->privateKey()->delete();
$privateKey = $github_app->privateKey;
if ($privateKey) {
// Check if key is used by anything EXCEPT this GitHub app
$isUsedElsewhere = $privateKey->servers()->exists()
|| $privateKey->applications()->exists()
|| $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists()
|| $privateKey->gitlabApps()->exists();
if (! $isUsedElsewhere) {
$privateKey->delete();
} else {
}
}
});
}

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.438',
'version' => '4.0.0-beta.439',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -106,7 +106,7 @@
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0" step="0.0000001"
type="number" min="0"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
</div>
</div>
@ -122,7 +122,7 @@
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0" step="0.0000001"
type="number" min="0"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
</div>
</div>

View file

@ -14,13 +14,14 @@
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}' target='_blank'
class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> choose
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> choose
Project Security API Tokens.
@if ($provider === 'hetzner')
<br><br>
@ -28,7 +29,7 @@ class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choos
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
and gives you €20)</span>
@endif
</div>
@endif
@ -49,7 +50,8 @@ class='underline dark:text-white'>Sign up here</a>
</div>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
@ -60,7 +62,7 @@ class='underline dark:text-white'>Hetzner Console</a> → choose Project → Sec
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
and gives you €20)</span>
</div>
@endif
</div>

View file

@ -20,16 +20,24 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
</div>
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
@can('delete', $savedToken)
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
'This cloud provider token will be permanently deleted.',
'Any servers using this token will need to be reconfigured.',
]"
confirmationText="{{ $savedToken->name }}"
confirmationLabel="Please confirm the deletion by entering the token name below"
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
@endcan
<div class="flex gap-2 pt-2">
@can('view', $savedToken)
<x-forms.button wire:click="validateToken({{ $savedToken->id }})" type="button">
Validate Token
</x-forms.button>
@endcan
@can('delete', $savedToken)
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
'This cloud provider token will be permanently deleted.',
'Any servers using this token will need to be reconfigured.',
]"
confirmationText="{{ $savedToken->name }}"
confirmationLabel="Please confirm the deletion by entering the token name below"
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
@endcan
</div>
</div>
@empty
<div>

View file

@ -42,7 +42,7 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full gap-2 xl:flex-row">
<x-forms.input required id="smtpHost" placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input required id="smtpPort" placeholder="587" label="Port" />
<x-forms.input required id="smtpPort" type="number" placeholder="587" label="Port" />
<x-forms.select required id="smtpEncryption" label="Encryption">
<option value="starttls">StartTLS</option>
<option value="tls">TLS/SSL</option>

View file

@ -7,7 +7,7 @@
services:
activepieces:
image: "ghcr.io/activepieces/activepieces:latest"
image: "ghcr.io/activepieces/activepieces:0.21.0" # Released on March 13 2024
environment:
- SERVICE_URL_ACTIVEPIECES
- AP_API_KEY=$SERVICE_PASSWORD_64_APIKEY
@ -40,7 +40,7 @@ services:
timeout: 20s
retries: 10
postgres:
image: "postgres:latest"
image: 'postgres:14.4'
environment:
- POSTGRES_DB=${POSTGRES_DB:-activepieces}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
@ -54,7 +54,7 @@ services:
timeout: 20s
retries: 10
redis:
image: "redis:latest"
image: 'redis:7.0.7'
volumes:
- "redis_data:/data"
healthcheck:

View file

@ -9,14 +9,14 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
image: 'henrygd/beszel:0.12.10'
image: 'henrygd/beszel:0.15.2' # Released on October 30 2025
environment:
- SERVICE_URL_BESZEL_8090
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
beszel-agent:
image: 'henrygd/beszel-agent:0.12.10'
image: 'henrygd/beszel-agent:0.15.2' # Released on October 30 2025
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket

View file

@ -1,5 +1,11 @@
<?php
use App\Livewire\Server\New\ByHetzner;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
// Note: Full Livewire integration tests require database setup
// These tests verify the SSH key merging logic and public_net configuration
@ -134,3 +140,49 @@
expect($sshKeys)->toBe([123, 456, 789])
->and(count($sshKeys))->toBe(3);
});
describe('Boarding Flow Integration', function () {
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner that has boarding enabled
$this->team = Team::factory()->create([
'show_boarding' => true,
]);
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Set current team and act as user
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('completes boarding when server is created from onboarding', function () {
// Verify boarding is initially enabled
expect($this->team->fresh()->show_boarding)->toBeTrue();
// Mount the component with from_onboarding flag
$component = Livewire::test(ByHetzner::class)
->set('from_onboarding', true);
// Verify the from_onboarding property is set
expect($component->get('from_onboarding'))->toBeTrue();
// After successful server creation in the actual component,
// the boarding should be marked as complete
// Note: We can't fully test the createServer method without mocking Hetzner API
// but we can verify the boarding completion logic is in place
});
test('boarding flag remains unchanged when not from onboarding', function () {
// Verify boarding is initially enabled
expect($this->team->fresh()->show_boarding)->toBeTrue();
// Mount the component without from_onboarding flag (default false)
Livewire::test(ByHetzner::class)
->set('from_onboarding', false);
// Boarding should still be enabled since it wasn't created from onboarding
expect($this->team->fresh()->show_boarding)->toBeTrue();
});
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.438"
"version": "4.0.0-beta.439"
},
"nightly": {
"version": "4.0.0-beta.439"
"version": "4.0.0-beta.440"
},
"helper": {
"version": "1.0.11"