diff --git a/app/Console/Commands/UpdateServiceVersions.php b/app/Console/Commands/UpdateServiceVersions.php new file mode 100644 index 000000000..1bd6708fd --- /dev/null +++ b/app/Console/Commands/UpdateServiceVersions.php @@ -0,0 +1,791 @@ + 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.'); + } + } +} diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index f05b3c0ca..cfef30772 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -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 { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 7a9b58b70..f7d12dbc1 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -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)); } diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 0d643306c..ab82c9a9c 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -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 { + } + } }); } diff --git a/config/constants.php b/config/constants.php index 813594e61..503fe3808 100644 --- a/config/constants.php +++ b/config/constants.php @@ -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), diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 94a187ad8..bb5dcfc4d 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -106,7 +106,7 @@ min="0" helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." /> @@ -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." /> diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 31bd76252..e803aa00c 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -14,13 +14,14 @@ - + @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the {{ ucfirst($provider) }} Console → choose + href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}' + target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console → choose Project → Security → API Tokens. @if ($provider === 'hetzner')

@@ -28,7 +29,7 @@ class='underline dark:text-white'>{{ ucfirst($provider) }} Console → choos class='underline dark:text-white'>Sign up here
(Coolify's affiliate link, only new accounts - supports us (€10) - and gives you €20) + and gives you €20) @endif
@endif @@ -49,7 +50,8 @@ class='underline dark:text-white'>Sign up here
- + @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the Hetzner Console → choose Project → Sec class='underline dark:text-white'>Sign up here
(Coolify's affiliate link, only new accounts - supports us (€10) - and gives you €20) + and gives you €20)
@endif
diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php index b3239c4a8..32a2cd2ab 100644 --- a/resources/views/livewire/security/cloud-provider-tokens.blade.php +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -20,16 +20,24 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
Created: {{ $savedToken->created_at->diffForHumans() }}
- @can('delete', $savedToken) - - @endcan +
+ @can('view', $savedToken) + + Validate Token + + @endcan + + @can('delete', $savedToken) + + @endcan +
@empty
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index 81cbcd09c..c58ea189d 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -42,7 +42,7 @@
- + diff --git a/templates/compose/activepieces.yaml b/templates/compose/activepieces.yaml index e9156336e..b5fc39daf 100644 --- a/templates/compose/activepieces.yaml +++ b/templates/compose/activepieces.yaml @@ -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: diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index 45b57a91b..153deaf8f 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -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 diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php index c939c0041..8f1a13d7a 100644 --- a/tests/Feature/HetznerServerCreationTest.php +++ b/tests/Feature/HetznerServerCreationTest.php @@ -1,5 +1,11 @@ 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(); + }); +}); diff --git a/versions.json b/versions.json index c7e173833..edf4a3700 100644 --- a/versions.json +++ b/versions.json @@ -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"