diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..24c099119 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,2 @@ +reviews: + review_status: false diff --git a/.gitignore b/.gitignore index 65b7faa1b..935ea548e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ scripts/load-test/* docker/coolify-realtime/node_modules .DS_Store CHANGELOG.md +/.workspaces diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 392562167..6bf094c32 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -20,7 +20,7 @@ class CleanupDocker $realtimeImageWithoutPrefix = 'coollabsio/coolify-realtime'; $realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion"; - $helperImageVersion = data_get($settings, 'helper_version'); + $helperImageVersion = getHelperVersion(); $helperImage = config('constants.coolify.helper_image'); $helperImageWithVersion = "$helperImage:$helperImageVersion"; $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; 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/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 73690a05b..0d38b7363 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1619,6 +1619,18 @@ class DatabasesController extends Controller return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } + if ($request->has('public_port') && $request->is_public) { if (isPublicPortAlreadyUsed($server, $request->public_port)) { return response()->json(['message' => 'Public port already used by another database.'], 400); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 971c1d806..a240a759a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1780,9 +1780,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function prepare_builder_image(bool $firstTry = true) { $this->checkForCancellation(); - $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); - $helperImage = "{$helperImage}:{$settings->helper_version}"; + $helperImage = "{$helperImage}:".getHelperVersion(); // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); @@ -2322,8 +2321,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo')); } $custom_network_aliases = []; - if (is_array($this->application->custom_network_aliases) && count($this->application->custom_network_aliases) > 0) { - $custom_network_aliases = $this->application->custom_network_aliases; + if (! empty($this->application->custom_network_aliases_array)) { + $custom_network_aliases = $this->application->custom_network_aliases_array; } $docker_compose = [ 'services' => [ diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 11da6fac1..45586f0d0 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -653,9 +653,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue private function getFullImageName(): string { - $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); - $latestVersion = $settings->helper_version; + $latestVersion = getHelperVersion(); return "{$helperImage}:{$latestVersion}"; } diff --git a/app/Jobs/PullHelperImageJob.php b/app/Jobs/PullHelperImageJob.php index b92886d38..7cdf1b81a 100644 --- a/app/Jobs/PullHelperImageJob.php +++ b/app/Jobs/PullHelperImageJob.php @@ -24,7 +24,7 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { $helperImage = config('constants.coolify.helper_image'); - $latest_version = instanceSettings()->helper_version; + $latest_version = getHelperVersion(); instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false); } } 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 @@ class CloudProviderTokens extends Component $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 @@ class ByHetzner extends Component $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/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 13d690352..96f13b173 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -35,6 +35,9 @@ class Index extends Component #[Validate('required|string|timezone')] public string $instance_timezone; + #[Validate('nullable|string|max:50')] + public ?string $dev_helper_version = null; + public array $domainConflicts = []; public bool $showDomainConflictModal = false; @@ -60,6 +63,7 @@ class Index extends Component $this->public_ipv4 = $this->settings->public_ipv4; $this->public_ipv6 = $this->settings->public_ipv6; $this->instance_timezone = $this->settings->instance_timezone; + $this->dev_helper_version = $this->settings->dev_helper_version; } #[Computed] @@ -81,6 +85,7 @@ class Index extends Component $this->settings->public_ipv4 = $this->public_ipv4; $this->settings->public_ipv6 = $this->public_ipv6; $this->settings->instance_timezone = $this->instance_timezone; + $this->settings->dev_helper_version = $this->dev_helper_version; if ($isSave) { $this->settings->save(); $this->dispatch('success', 'Settings updated!'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 32459f752..615e35f68 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -120,7 +120,6 @@ class Application extends BaseModel protected $appends = ['server_status']; protected $casts = [ - 'custom_network_aliases' => 'array', 'http_basic_auth_password' => 'encrypted', ]; @@ -253,6 +252,30 @@ class Application extends BaseModel return null; } + if (is_string($value) && $this->isJson($value)) { + $decoded = json_decode($value, true); + + // Return as comma-separated string, not array + return is_array($decoded) ? implode(',', $decoded) : $value; + } + + return $value; + } + ); + } + + /** + * Get custom_network_aliases as an array + */ + public function customNetworkAliasesArray(): Attribute + { + return Attribute::make( + get: function () { + $value = $this->getRawOriginal('custom_network_aliases'); + if (is_null($value)) { + return null; + } + if (is_string($value) && $this->isJson($value)) { return json_decode($value, true); } @@ -957,7 +980,7 @@ class Application extends BaseModel public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { 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 @@ class GithubApp extends BaseModel 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/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 5d0f9a2a7..488653fb1 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -97,6 +97,7 @@ function sharedDataApplications() 'start_command' => 'string|nullable', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', + 'custom_network_aliases' => 'string|nullable', 'base_directory' => 'string|nullable', 'publish_directory' => 'string|nullable', 'health_check_enabled' => 'boolean', diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 0f5b6f553..effde712a 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2879,6 +2879,18 @@ function instanceSettings() return InstanceSettings::get(); } +function getHelperVersion(): string +{ + $settings = instanceSettings(); + + // In development mode, use the dev_helper_version if set, otherwise fallback to config + if (isDev() && ! empty($settings->dev_helper_version)) { + return $settings->dev_helper_version; + } + + return config('constants.coolify.helper_version'); +} + function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id) { $server = Server::find($server_id)->where('team_id', $team_id)->first(); 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/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php new file mode 100644 index 000000000..56ed2239a --- /dev/null +++ b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php @@ -0,0 +1,28 @@ +string('dev_helper_version')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('dev_helper_version'); + }); + } +}; diff --git a/gcool.json b/gcool.json new file mode 100644 index 000000000..629d8569a --- /dev/null +++ b/gcool.json @@ -0,0 +1,6 @@ +{ + "scripts": { + "onWorktreeCreate": "cp $GCOOL_ROOT_PATH/.env .", + "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up" + } +} 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/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 8d2ad665d..9e2af21cc 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -12,7 +12,7 @@
Deploy resources, like Applications, Databases, Services...
@if ($current_step === 'type') -
+
- + @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'>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 @@
- + @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the 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 @@
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/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 61a73d25c..4ceb2043a 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -76,6 +76,13 @@ helper="Enter the IPv6 address of the instance.

It is useful if you have several IPv6 addresses and Coolify could not detect the correct one." placeholder="2001:db8::1" autocomplete="new-password" />
+ @if(isDev()) +
+ +
+ @endif
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/templates/service-templates-latest.json b/templates/service-templates-latest.json index dfabce600..4d365b483 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2,7 +2,7 @@ "activepieces": { "documentation": "https://www.activepieces.com/docs/getting-started/introduction?utm_source=coolify.io", "slogan": "Open source no-code business automation.", - "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTCiAgICAgIC0gQVBfQVBJX0tFWT0kU0VSVklDRV9QQVNTV09SRF82NF9BUElLRVkKICAgICAgLSBBUF9FTkNSWVBUSU9OX0tFWT0kU0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OS0VZCiAgICAgIC0gJ0FQX0VOR0lORV9FWEVDVVRBQkxFX1BBVEg9JHtBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIOi1kaXN0L3BhY2thZ2VzL2VuZ2luZS9tYWluLmpzfScKICAgICAgLSAnQVBfRU5WSVJPTk1FTlQ9JHtBUF9FTlZJUk9OTUVOVDotcHJvZH0nCiAgICAgIC0gJ0FQX0VYRUNVVElPTl9NT0RFPSR7QVBfRVhFQ1VUSU9OX01PREU6LVVOU0FOREJPWEVEfScKICAgICAgLSAnQVBfRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTfScKICAgICAgLSBBUF9KV1RfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0pXVAogICAgICAtICdBUF9QT1NUR1JFU19EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1hY3RpdmVwaWVjZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdBUF9QT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gQVBfUE9TVEdSRVNfVVNFUk5BTUU9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtICdBUF9SRURJU19IT1NUPSR7UkVESVNfSE9TVDotcmVkaXN9JwogICAgICAtICdBUF9SRURJU19QT1JUPSR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUz0ke0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUzotNjAwfScKICAgICAgLSAnQVBfVEVMRU1FVFJZX0VOQUJMRUQ9JHtBUF9URUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdBUF9URU1QTEFURVNfU09VUkNFX1VSTD0ke0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMOi1odHRwczovL2Nsb3VkLmFjdGl2ZXBpZWNlcy5jb20vYXBpL3YxL2Zsb3ctdGVtcGxhdGVzfScKICAgICAgLSAnQVBfVFJJR0dFUl9ERUZBVUxUX1BPTExfSU5URVJWQUw9JHtBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTDotNX0nCiAgICAgIC0gJ0FQX1dFQkhPT0tfVElNRU9VVF9TRUNPTkRTPSR7QVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM6LTMwfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwZy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6MC4yMS4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTCiAgICAgIC0gQVBfQVBJX0tFWT0kU0VSVklDRV9QQVNTV09SRF82NF9BUElLRVkKICAgICAgLSBBUF9FTkNSWVBUSU9OX0tFWT0kU0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OS0VZCiAgICAgIC0gJ0FQX0VOR0lORV9FWEVDVVRBQkxFX1BBVEg9JHtBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIOi1kaXN0L3BhY2thZ2VzL2VuZ2luZS9tYWluLmpzfScKICAgICAgLSAnQVBfRU5WSVJPTk1FTlQ9JHtBUF9FTlZJUk9OTUVOVDotcHJvZH0nCiAgICAgIC0gJ0FQX0VYRUNVVElPTl9NT0RFPSR7QVBfRVhFQ1VUSU9OX01PREU6LVVOU0FOREJPWEVEfScKICAgICAgLSAnQVBfRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTfScKICAgICAgLSBBUF9KV1RfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0pXVAogICAgICAtICdBUF9QT1NUR1JFU19EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1hY3RpdmVwaWVjZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdBUF9QT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gQVBfUE9TVEdSRVNfVVNFUk5BTUU9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtICdBUF9SRURJU19IT1NUPSR7UkVESVNfSE9TVDotcmVkaXN9JwogICAgICAtICdBUF9SRURJU19QT1JUPSR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUz0ke0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUzotNjAwfScKICAgICAgLSAnQVBfVEVMRU1FVFJZX0VOQUJMRUQ9JHtBUF9URUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdBUF9URU1QTEFURVNfU09VUkNFX1VSTD0ke0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMOi1odHRwczovL2Nsb3VkLmFjdGl2ZXBpZWNlcy5jb20vYXBpL3YxL2Zsb3ctdGVtcGxhdGVzfScKICAgICAgLSAnQVBfVFJJR0dFUl9ERUZBVUxUX1BPTExfSU5URVJWQUw9JHtBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTDotNX0nCiAgICAgIC0gJ0FQX1dFQkhPT0tfVElNRU9VVF9TRUNPTkRTPSR7QVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM6LTMwfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1hY3RpdmVwaWVjZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncGctZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMC43JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "workflow", "automation", @@ -189,7 +189,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjEyLjEwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTIuMTAnCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScK", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE1LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCg==", "tags": [ "beszel", "monitoring", diff --git a/templates/service-templates.json b/templates/service-templates.json index 3d49b1620..d711b9d95 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2,7 +2,7 @@ "activepieces": { "documentation": "https://www.activepieces.com/docs/getting-started/introduction?utm_source=coolify.io", "slogan": "Open source no-code business automation.", - "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FDVElWRVBJRUNFUwogICAgICAtIEFQX0FQSV9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBJS0VZCiAgICAgIC0gQVBfRU5DUllQVElPTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTktFWQogICAgICAtICdBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIPSR7QVBfRU5HSU5FX0VYRUNVVEFCTEVfUEFUSDotZGlzdC9wYWNrYWdlcy9lbmdpbmUvbWFpbi5qc30nCiAgICAgIC0gJ0FQX0VOVklST05NRU5UPSR7QVBfRU5WSVJPTk1FTlQ6LXByb2R9JwogICAgICAtICdBUF9FWEVDVVRJT05fTU9ERT0ke0FQX0VYRUNVVElPTl9NT0RFOi1VTlNBTkRCT1hFRH0nCiAgICAgIC0gJ0FQX0ZST05URU5EX1VSTD0ke1NFUlZJQ0VfRlFETl9BQ1RJVkVQSUVDRVN9JwogICAgICAtIEFQX0pXVF9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSldUCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBBUF9QT1NUR1JFU19VU0VSTkFNRT0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gJ0FQX1JFRElTX0hPU1Q9JHtSRURJU19IT1NUOi1yZWRpc30nCiAgICAgIC0gJ0FQX1JFRElTX1BPUlQ9JHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnQVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTPSR7QVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTOi02MDB9JwogICAgICAtICdBUF9URUxFTUVUUllfRU5BQkxFRD0ke0FQX1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMPSR7QVBfVEVNUExBVEVTX1NPVVJDRV9VUkw6LWh0dHBzOi8vY2xvdWQuYWN0aXZlcGllY2VzLmNvbS9hcGkvdjEvZmxvdy10ZW1wbGF0ZXN9JwogICAgICAtICdBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTD0ke0FQX1RSSUdHRVJfREVGQVVMVF9QT0xMX0lOVEVSVkFMOi01fScKICAgICAgLSAnQVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM9JHtBUF9XRUJIT09LX1RJTUVPVVRfU0VDT05EUzotMzB9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYWN0aXZlcGllY2VzfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3BnLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6MC4yMS4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FDVElWRVBJRUNFUwogICAgICAtIEFQX0FQSV9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBJS0VZCiAgICAgIC0gQVBfRU5DUllQVElPTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTktFWQogICAgICAtICdBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIPSR7QVBfRU5HSU5FX0VYRUNVVEFCTEVfUEFUSDotZGlzdC9wYWNrYWdlcy9lbmdpbmUvbWFpbi5qc30nCiAgICAgIC0gJ0FQX0VOVklST05NRU5UPSR7QVBfRU5WSVJPTk1FTlQ6LXByb2R9JwogICAgICAtICdBUF9FWEVDVVRJT05fTU9ERT0ke0FQX0VYRUNVVElPTl9NT0RFOi1VTlNBTkRCT1hFRH0nCiAgICAgIC0gJ0FQX0ZST05URU5EX1VSTD0ke1NFUlZJQ0VfRlFETl9BQ1RJVkVQSUVDRVN9JwogICAgICAtIEFQX0pXVF9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSldUCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBBUF9QT1NUR1JFU19VU0VSTkFNRT0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gJ0FQX1JFRElTX0hPU1Q9JHtSRURJU19IT1NUOi1yZWRpc30nCiAgICAgIC0gJ0FQX1JFRElTX1BPUlQ9JHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnQVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTPSR7QVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTOi02MDB9JwogICAgICAtICdBUF9URUxFTUVUUllfRU5BQkxFRD0ke0FQX1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMPSR7QVBfVEVNUExBVEVTX1NPVVJDRV9VUkw6LWh0dHBzOi8vY2xvdWQuYWN0aXZlcGllY2VzLmNvbS9hcGkvdjEvZmxvdy10ZW1wbGF0ZXN9JwogICAgICAtICdBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTD0ke0FQX1RSSUdHRVJfREVGQVVMVF9QT0xMX0lOVEVSVkFMOi01fScKICAgICAgLSAnQVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM9JHtBUF9XRUJIT09LX1RJTUVPVVRfU0VDT05EUzotMzB9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwZy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4wLjcnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "workflow", "automation", @@ -189,7 +189,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjEyLjEwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JFU1pFTF84MDkwCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfZGF0YTovYmVzemVsX2RhdGEnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjEyLjEwJwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE1LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTUuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD1odHRwOi8vYmVzemVsOjgwOTAnCiAgICAgIC0gJ1RPS0VOPSR7VE9LRU59JwogICAgICAtICdLRVk9JHtLRVl9Jwo=", "tags": [ "beszel", "monitoring", 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/tests/Unit/ApplicationConfigurationChangeTest.php b/tests/Unit/ApplicationConfigurationChangeTest.php new file mode 100644 index 000000000..618f3d033 --- /dev/null +++ b/tests/Unit/ApplicationConfigurationChangeTest.php @@ -0,0 +1,17 @@ +not->toBe($hash2) + ->and($hash1)->not->toBe($hash3) + ->and($hash2)->not->toBe($hash3); +}); diff --git a/tests/Unit/ApplicationNetworkAliasesSyncTest.php b/tests/Unit/ApplicationNetworkAliasesSyncTest.php new file mode 100644 index 000000000..552ac854c --- /dev/null +++ b/tests/Unit/ApplicationNetworkAliasesSyncTest.php @@ -0,0 +1,50 @@ +toBe('api.internal,api.local') + ->and($result)->toBeString(); +}); + +it('handles null aliases', function () { + // Test that null remains null + $aliases = null; + + if (is_array($aliases)) { + $result = implode(',', $aliases); + } else { + $result = $aliases; + } + + expect($result)->toBeNull(); +}); + +it('handles empty array aliases', function () { + // Test that empty array becomes empty string + $aliases = []; + $result = implode(',', $aliases); + + expect($result)->toBe('') + ->and($result)->toBeString(); +}); + +it('handles single alias', function () { + // Test that single-element array is converted correctly + $aliases = ['api.internal']; + $result = implode(',', $aliases); + + expect($result)->toBe('api.internal') + ->and($result)->toBeString(); +}); diff --git a/todos/service-database-deployment-logging.md b/todos/service-database-deployment-logging.md new file mode 100644 index 000000000..dd0790aec --- /dev/null +++ b/todos/service-database-deployment-logging.md @@ -0,0 +1,1916 @@ +# Service & Database Deployment Logging - Implementation Plan + +**Status:** Planning Complete +**Branch:** `andrasbacsai/service-db-deploy-logs` +**Target:** Add deployment history and logging for Services and Databases (similar to Applications) + +--- + +## Current State Analysis + +### Application Deployments (Working Model) + +**Model:** `ApplicationDeploymentQueue` +- **Location:** `app/Models/ApplicationDeploymentQueue.php` +- **Table:** `application_deployment_queues` +- **Key Features:** + - Stores deployment logs as JSON in `logs` column + - Tracks status: queued, in_progress, finished, failed, cancelled-by-user + - Stores metadata: deployment_uuid, commit, pull_request_id, server info + - Has `addLogEntry()` method with sensitive data redaction + - Relationships: belongsTo Application, server attribute accessor + +**Job:** `ApplicationDeploymentJob` +- **Location:** `app/Jobs/ApplicationDeploymentJob.php` +- Handles entire deployment lifecycle +- Uses `addLogEntry()` to stream logs to database +- Updates status throughout deployment + +**Helper Function:** `queue_application_deployment()` +- **Location:** `bootstrap/helpers/applications.php` +- Creates deployment queue record +- Dispatches job if ready +- Returns deployment status and UUID + +**API Endpoints:** +- `GET /api/deployments` - List all running deployments +- `GET /api/deployments/{uuid}` - Get specific deployment +- `GET /api/deployments/applications/{uuid}` - List app deployment history +- Sensitive data filtering based on permissions + +**Migration History:** +- `2023_05_24_083426_create_application_deployment_queues_table.php` +- `2023_06_23_114133_use_application_deployment_queues_as_activity.php` (added logs, current_process_id) +- `2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues.php` + +--- + +### Services (Current State - No History) + +**Model:** `Service` +- **Location:** `app/Models/Service.php` +- Represents Docker Compose services with multiple applications/databases + +**Action:** `StartService` +- **Location:** `app/Actions/Service/StartService.php` +- Executes commands via `remote_process()` +- Returns Activity log (Spatie ActivityLog) - ephemeral, not stored +- Fires `ServiceStatusChanged` event on completion + +**Current Behavior:** +```php +public function handle(Service $service, bool $pullLatestImages, bool $stopBeforeStart) +{ + $service->parse(); + // ... build commands array + return remote_process($commands, $service->server, + type_uuid: $service->uuid, + callEventOnFinish: 'ServiceStatusChanged'); +} +``` + +**Problem:** No persistent deployment history. Logs disappear after Activity TTL. + +--- + +### Databases (Current State - No History) + +**Models:** 9 Standalone Database Types +- `StandalonePostgresql` +- `StandaloneRedis` +- `StandaloneMongodb` +- `StandaloneMysql` +- `StandaloneMariadb` +- `StandaloneKeydb` +- `StandaloneDragonfly` +- `StandaloneClickhouse` +- (All in `app/Models/`) + +**Actions:** Type-Specific Start Actions +- `StartPostgresql`, `StartRedis`, `StartMongodb`, etc. +- **Location:** `app/Actions/Database/Start*.php` +- Each builds docker-compose config, writes to disk, starts container +- Uses `remote_process()` with `DatabaseStatusChanged` event + +**Dispatcher:** `StartDatabase` +- **Location:** `app/Actions/Database/StartDatabase.php` +- Routes to correct Start action based on database type + +**Current Behavior:** +```php +// StartPostgresql example +public function handle(StandalonePostgresql $database) +{ + // ... build commands array + return remote_process($this->commands, $database->destination->server, + callEventOnFinish: 'DatabaseStatusChanged'); +} +``` + +**Problem:** No persistent deployment history. Only real-time Activity logs. + +--- + +## Architectural Decisions + +### Why Separate Tables? + +**Decision:** Create `service_deployment_queues` and `database_deployment_queues` (two separate tables) + +**Reasoning:** +1. **Different Attributes:** + - Services: multiple containers, docker-compose specific, pull_latest_images flag + - Databases: type-specific configs, SSL settings, init scripts + - Applications: git commits, pull requests, build cache + +2. **Query Performance:** + - Separate indexes per resource type + - No polymorphic type checks in every query + - Easier to optimize per-resource-type + +3. **Type Safety:** + - Explicit relationships and foreign keys (where possible) + - IDE autocomplete and static analysis benefits + +4. **Existing Pattern:** + - Coolify already uses separate tables: `applications`, `services`, `standalone_*` + - Consistent with codebase conventions + +**Alternative Considered:** Single `resource_deployments` polymorphic table +- **Pros:** DRY, one model to maintain +- **Cons:** Harder to query efficiently, less type-safe, complex indexes +- **Decision:** Rejected in favor of clarity and performance + +--- + +## Implementation Plan + +### Phase 1: Database Schema (3 migrations) + +#### Migration 1: Create `service_deployment_queues` + +**File:** `database/migrations/YYYY_MM_DD_HHMMSS_create_service_deployment_queues_table.php` + +```php +Schema::create('service_deployment_queues', function (Blueprint $table) { + $table->id(); + $table->foreignId('service_id')->constrained()->onDelete('cascade'); + $table->string('deployment_uuid')->unique(); + $table->string('status')->default('queued'); // queued, in_progress, finished, failed, cancelled-by-user + $table->text('logs')->nullable(); // JSON array like ApplicationDeploymentQueue + $table->string('current_process_id')->nullable(); // For tracking background processes + $table->boolean('pull_latest_images')->default(false); + $table->boolean('stop_before_start')->default(false); + $table->boolean('is_api')->default(false); // Triggered via API vs UI + $table->string('server_id'); // Denormalized for performance + $table->string('server_name'); // Denormalized for display + $table->string('service_name'); // Denormalized for display + $table->string('deployment_url')->nullable(); // URL to view deployment + $table->timestamps(); + + // Indexes for common queries + $table->index(['service_id', 'status']); + $table->index('deployment_uuid'); + $table->index('created_at'); +}); +``` + +**Key Design Choices:** +- `logs` as TEXT (JSON) - Same pattern as ApplicationDeploymentQueue +- Denormalized server/service names for API responses without joins +- `deployment_url` for direct link generation +- Composite indexes for filtering by service + status + +--- + +#### Migration 2: Create `database_deployment_queues` + +**File:** `database/migrations/YYYY_MM_DD_HHMMSS_create_database_deployment_queues_table.php` + +```php +Schema::create('database_deployment_queues', function (Blueprint $table) { + $table->id(); + $table->string('database_id'); // String to support polymorphic relationship + $table->string('database_type'); // StandalonePostgresql, StandaloneRedis, etc. + $table->string('deployment_uuid')->unique(); + $table->string('status')->default('queued'); + $table->text('logs')->nullable(); + $table->string('current_process_id')->nullable(); + $table->boolean('is_api')->default(false); + $table->string('server_id'); + $table->string('server_name'); + $table->string('database_name'); + $table->string('deployment_url')->nullable(); + $table->timestamps(); + + // Indexes for polymorphic relationship and queries + $table->index(['database_id', 'database_type']); + $table->index(['database_id', 'database_type', 'status']); + $table->index('deployment_uuid'); + $table->index('created_at'); +}); +``` + +**Key Design Choices:** +- Polymorphic relationship using `database_id` + `database_type` +- Can't use foreignId constraint due to multiple target tables +- Composite index on polymorphic keys for efficient queries + +--- + +#### Migration 3: Add Performance Indexes + +**File:** `database/migrations/YYYY_MM_DD_HHMMSS_add_deployment_queue_indexes.php` + +```php +Schema::table('service_deployment_queues', function (Blueprint $table) { + $table->index(['server_id', 'status', 'created_at'], 'service_deployments_server_status_time'); +}); + +Schema::table('database_deployment_queues', function (Blueprint $table) { + $table->index(['server_id', 'status', 'created_at'], 'database_deployments_server_status_time'); +}); +``` + +**Purpose:** Optimize queries like "all in-progress deployments on this server, newest first" + +--- + +### Phase 2: Eloquent Models (2 new models) + +#### Model 1: ServiceDeploymentQueue + +**File:** `app/Models/ServiceDeploymentQueue.php` + +```php + ['type' => 'integer'], + 'service_id' => ['type' => 'integer'], + 'deployment_uuid' => ['type' => 'string'], + 'status' => ['type' => 'string'], + 'pull_latest_images' => ['type' => 'boolean'], + 'stop_before_start' => ['type' => 'boolean'], + 'is_api' => ['type' => 'boolean'], + 'logs' => ['type' => 'string'], + 'current_process_id' => ['type' => 'string'], + 'server_id' => ['type' => 'string'], + 'server_name' => ['type' => 'string'], + 'service_name' => ['type' => 'string'], + 'deployment_url' => ['type' => 'string'], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ], +)] +class ServiceDeploymentQueue extends Model +{ + protected $guarded = []; + + public function service() + { + return $this->belongsTo(Service::class); + } + + public function server(): Attribute + { + return Attribute::make( + get: fn () => Server::find($this->server_id), + ); + } + + public function setStatus(string $status) + { + $this->update(['status' => $status]); + } + + public function getOutput($name) + { + if (!$this->logs) { + return null; + } + return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null; + } + + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); // Remove internal IPs + + $service = $this->service; + if (!$service) { + return $text; + } + + // Redact environment variables marked as sensitive + $lockedVars = collect([]); + if ($service->environment_variables) { + $lockedVars = $service->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter(); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace('/' . $escapedValue . '/', REDACTED, $text); + } + + return $text; + } + + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) + { + if ($type === 'error') { + $type = 'stderr'; + } + + $message = str($message)->trim(); + if ($message->startsWith('╔')) { + $message = "\n" . $message; + } + + $newLogEntry = [ + 'command' => null, + 'output' => $this->redactSensitiveInfo($message), + 'type' => $type, + 'timestamp' => Carbon::now('UTC'), + 'hidden' => $hidden, + 'batch' => 1, + ]; + + // Use transaction for atomicity + DB::transaction(function () use ($newLogEntry) { + $this->refresh(); + + if ($this->logs) { + $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR); + $newLogEntry['order'] = count($previousLogs) + 1; + $previousLogs[] = $newLogEntry; + $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR); + } else { + $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR); + } + + $this->saveQuietly(); + }); + } +} +``` + +**Key Features:** +- Exact same log structure as ApplicationDeploymentQueue +- `addLogEntry()` with sensitive data redaction +- Atomic log appends using DB transactions +- OpenAPI schema for API documentation + +--- + +#### Model 2: DatabaseDeploymentQueue + +**File:** `app/Models/DatabaseDeploymentQueue.php` + +```php + ['type' => 'integer'], + 'database_id' => ['type' => 'string'], + 'database_type' => ['type' => 'string'], + 'deployment_uuid' => ['type' => 'string'], + 'status' => ['type' => 'string'], + 'is_api' => ['type' => 'boolean'], + 'logs' => ['type' => 'string'], + 'current_process_id' => ['type' => 'string'], + 'server_id' => ['type' => 'string'], + 'server_name' => ['type' => 'string'], + 'database_name' => ['type' => 'string'], + 'deployment_url' => ['type' => 'string'], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ], +)] +class DatabaseDeploymentQueue extends Model +{ + protected $guarded = []; + + public function database() + { + return $this->morphTo('database', 'database_type', 'database_id'); + } + + public function server(): Attribute + { + return Attribute::make( + get: fn () => Server::find($this->server_id), + ); + } + + public function setStatus(string $status) + { + $this->update(['status' => $status]); + } + + public function getOutput($name) + { + if (!$this->logs) { + return null; + } + return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null; + } + + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $database = $this->database; + if (!$database) { + return $text; + } + + // Redact database-specific credentials + $sensitivePatterns = collect([]); + + // Common database credential patterns + if (method_exists($database, 'getConnectionString')) { + $sensitivePatterns->push($database->getConnectionString()); + } + + // Postgres/MySQL passwords + $passwordFields = ['postgres_password', 'mysql_password', 'mariadb_password', 'mongo_password']; + foreach ($passwordFields as $field) { + if (isset($database->$field)) { + $sensitivePatterns->push($database->$field); + } + } + + // Redact environment variables + if ($database->environment_variables) { + $lockedVars = $database->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value') + ->filter(); + $sensitivePatterns = $sensitivePatterns->merge($lockedVars); + } + + foreach ($sensitivePatterns as $value) { + if (empty($value)) continue; + $escapedValue = preg_quote($value, '/'); + $text = preg_replace('/' . $escapedValue . '/', REDACTED, $text); + } + + return $text; + } + + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) + { + if ($type === 'error') { + $type = 'stderr'; + } + + $message = str($message)->trim(); + if ($message->startsWith('╔')) { + $message = "\n" . $message; + } + + $newLogEntry = [ + 'command' => null, + 'output' => $this->redactSensitiveInfo($message), + 'type' => $type, + 'timestamp' => Carbon::now('UTC'), + 'hidden' => $hidden, + 'batch' => 1, + ]; + + DB::transaction(function () use ($newLogEntry) { + $this->refresh(); + + if ($this->logs) { + $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR); + $newLogEntry['order'] = count($previousLogs) + 1; + $previousLogs[] = $newLogEntry; + $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR); + } else { + $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR); + } + + $this->saveQuietly(); + }); + } +} +``` + +**Key Differences from ServiceDeploymentQueue:** +- Polymorphic `database()` relationship +- More extensive sensitive data redaction (database passwords, connection strings) +- Handles all 9 database types + +--- + +### Phase 3: Enums (2 new enums) + +#### Enum 1: ServiceDeploymentStatus + +**File:** `app/Enums/ServiceDeploymentStatus.php` + +```php +id; + $server = $service->destination->server; + $server_id = $server->id; + $server_name = $server->name; + + // Generate deployment URL + $deployment_link = Url::fromString($service->link() . "/deployment/{$deployment_uuid}"); + $deployment_url = $deployment_link->getPath(); + + // Create deployment record + $deployment = ServiceDeploymentQueue::create([ + 'service_id' => $service_id, + 'service_name' => $service->name, + 'server_id' => $server_id, + 'server_name' => $server_name, + 'deployment_uuid' => $deployment_uuid, + 'deployment_url' => $deployment_url, + 'pull_latest_images' => $pullLatestImages, + 'stop_before_start' => $stopBeforeStart, + 'is_api' => $is_api, + 'status' => ServiceDeploymentStatus::IN_PROGRESS->value, + ]); + + return [ + 'status' => 'started', + 'message' => 'Service deployment started.', + 'deployment_uuid' => $deployment_uuid, + 'deployment' => $deployment, + ]; +} +``` + +**Purpose:** Create deployment queue record when service starts. Returns deployment object for passing to actions. + +--- + +#### Helper 2: queue_database_deployment() + +**File:** `bootstrap/helpers/databases.php` (add to existing file) + +```php +use App\Models\DatabaseDeploymentQueue; +use App\Enums\DatabaseDeploymentStatus; +use Spatie\Url\Url; +use Visus\Cuid2\Cuid2; + +function queue_database_deployment( + StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, + string $deployment_uuid, + bool $is_api = false +): array { + $database_id = $database->id; + $database_type = $database->getMorphClass(); + $server = $database->destination->server; + $server_id = $server->id; + $server_name = $server->name; + + // Generate deployment URL + $deployment_link = Url::fromString($database->link() . "/deployment/{$deployment_uuid}"); + $deployment_url = $deployment_link->getPath(); + + // Create deployment record + $deployment = DatabaseDeploymentQueue::create([ + 'database_id' => $database_id, + 'database_type' => $database_type, + 'database_name' => $database->name, + 'server_id' => $server_id, + 'server_name' => $server_name, + 'deployment_uuid' => $deployment_uuid, + 'deployment_url' => $deployment_url, + 'is_api' => $is_api, + 'status' => DatabaseDeploymentStatus::IN_PROGRESS->value, + ]); + + return [ + 'status' => 'started', + 'message' => 'Database deployment started.', + 'deployment_uuid' => $deployment_uuid, + 'deployment' => $deployment, + ]; +} +``` + +--- + +### Phase 5: Refactor Actions (11 files to update) + +#### Action 1: StartService (CRITICAL) + +**File:** `app/Actions/Service/StartService.php` + +**Before:** +```php +public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) +{ + $service->parse(); + // ... build commands + return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); +} +``` + +**After:** +```php +use App\Models\ServiceDeploymentQueue; +use Visus\Cuid2\Cuid2; + +public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) +{ + // Create deployment queue record + $deployment_uuid = (string) new Cuid2(); + $result = queue_service_deployment( + service: $service, + deployment_uuid: $deployment_uuid, + pullLatestImages: $pullLatestImages, + stopBeforeStart: $stopBeforeStart, + is_api: false + ); + $deployment = $result['deployment']; + + // Existing logic + $service->parse(); + if ($stopBeforeStart) { + StopService::run(service: $service, dockerCleanup: false); + } + $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); + + $commands[] = 'cd ' . $service->workdir(); + $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'"; + // ... rest of command building + + // Pass deployment to remote_process for log streaming + return remote_process( + $commands, + $service->server, + type_uuid: $service->uuid, + model: $deployment, // NEW - link to deployment queue + callEventOnFinish: 'ServiceStatusChanged' + ); +} +``` + +**Key Changes:** +1. Generate deployment UUID at start +2. Call `queue_service_deployment()` helper +3. Pass `$deployment` as `model` parameter to `remote_process()` +4. Return value unchanged (Activity object) + +--- + +#### Actions 2-10: Database Start Actions (9 files) + +**Files to Update:** +- `app/Actions/Database/StartPostgresql.php` +- `app/Actions/Database/StartRedis.php` +- `app/Actions/Database/StartMongodb.php` +- `app/Actions/Database/StartMysql.php` +- `app/Actions/Database/StartMariadb.php` +- `app/Actions/Database/StartKeydb.php` +- `app/Actions/Database/StartDragonfly.php` +- `app/Actions/Database/StartClickhouse.php` + +**Pattern (using StartPostgresql as example):** + +**Before:** +```php +public function handle(StandalonePostgresql $database) +{ + $this->database = $database; + // ... build docker-compose and commands + return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); +} +``` + +**After:** +```php +use App\Models\DatabaseDeploymentQueue; +use Visus\Cuid2\Cuid2; + +public function handle(StandalonePostgresql $database) +{ + $this->database = $database; + + // Create deployment queue record + $deployment_uuid = (string) new Cuid2(); + $result = queue_database_deployment( + database: $database, + deployment_uuid: $deployment_uuid, + is_api: false + ); + $deployment = $result['deployment']; + + // Existing logic (unchanged) + $container_name = $this->database->uuid; + $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + // ... rest of setup + + // Pass deployment to remote_process + return remote_process( + $this->commands, + $database->destination->server, + model: $deployment, // NEW + callEventOnFinish: 'DatabaseStatusChanged' + ); +} +``` + +**Apply Same Pattern to All 9 Database Start Actions** + +--- + +#### Action 11: StartDatabase (Dispatcher) + +**File:** `app/Actions/Database/StartDatabase.php` + +**Before:** +```php +public function handle(/* all database types */) +{ + switch ($database->getMorphClass()) { + case \App\Models\StandalonePostgresql::class: + $activity = StartPostgresql::run($database); + break; + // ... other cases + } + return $activity; +} +``` + +**After:** No changes needed - already returns Activity from Start* actions + +--- + +### Phase 6: Update Remote Process Handler (CRITICAL) + +**File:** `app/Actions/CoolifyTask/PrepareCoolifyTask.php` + +**Current Behavior:** +- Accepts `$model` parameter (currently only used for ApplicationDeploymentQueue) +- Streams logs to Activity (Spatie ActivityLog) +- Calls event on finish + +**Required Changes:** +1. Check if `$model` is `ServiceDeploymentQueue` or `DatabaseDeploymentQueue` +2. Call `addLogEntry()` on deployment model alongside Activity logs +3. Update deployment status on completion/failure + +**Pseudocode for Changes:** +```php +// In log streaming section +if ($model instanceof ApplicationDeploymentQueue || + $model instanceof ServiceDeploymentQueue || + $model instanceof DatabaseDeploymentQueue) { + $model->addLogEntry($logMessage, $logType); +} + +// On completion +if ($model instanceof ServiceDeploymentQueue || + $model instanceof DatabaseDeploymentQueue) { + if ($exitCode === 0) { + $model->setStatus('finished'); + } else { + $model->setStatus('failed'); + } +} +``` + +**Note:** Exact implementation depends on PrepareCoolifyTask structure. Need to review file in detail during implementation. + +--- + +### Phase 7: API Endpoints (4 new endpoints + 2 updates) + +**File:** `app/Http/Controllers/Api/DeployController.php` + +#### Endpoint 1: List Service Deployments + +```php +#[OA\Get( + summary: 'List service deployments', + description: 'List deployment history for a specific service', + path: '/deployments/services/{uuid}', + operationId: 'list-deployments-by-service-uuid', + security: [['bearerAuth' => []]], + tags: ['Deployments'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'skip', in: 'query', description: 'Number of records to skip', schema: new OA\Schema(type: 'integer', minimum: 0, default: 0)), + new OA\Parameter(name: 'take', in: 'query', description: 'Number of records to take', schema: new OA\Schema(type: 'integer', minimum: 1, default: 10)), + ], + responses: [ + new OA\Response(response: 200, description: 'List of service deployments'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] +)] +public function get_service_deployments(Request $request) +{ + $request->validate([ + 'skip' => ['nullable', 'integer', 'min:0'], + 'take' => ['nullable', 'integer', 'min:1'], + ]); + + $service_uuid = $request->route('uuid', null); + $skip = $request->get('skip', 0); + $take = $request->get('take', 10); + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::where('uuid', $service_uuid) + ->whereHas('environment.project.team', function($query) use ($teamId) { + $query->where('id', $teamId); + }) + ->first(); + + if (is_null($service)) { + return response()->json(['message' => 'Service not found'], 404); + } + + $this->authorize('view', $service); + + $deployments = $service->deployments($skip, $take); + + return response()->json(serializeApiResponse($deployments)); +} +``` + +#### Endpoint 2: Get Service Deployment by UUID + +```php +#[OA\Get( + summary: 'Get service deployment', + description: 'Get a specific service deployment by deployment UUID', + path: '/deployments/services/deployment/{uuid}', + operationId: 'get-service-deployment-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Deployments'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Service deployment details'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] +)] +public function service_deployment_by_uuid(Request $request) +{ + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + if (!$uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + + $deployment = ServiceDeploymentQueue::where('deployment_uuid', $uuid)->first(); + if (!$deployment) { + return response()->json(['message' => 'Deployment not found.'], 404); + } + + // Authorization check via service + $service = $deployment->service; + if (!$service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('view', $service); + + return response()->json($this->removeSensitiveData($deployment)); +} +``` + +#### Endpoint 3: List Database Deployments + +```php +#[OA\Get( + summary: 'List database deployments', + description: 'List deployment history for a specific database', + path: '/deployments/databases/{uuid}', + operationId: 'list-deployments-by-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Deployments'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Database UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'skip', in: 'query', schema: new OA\Schema(type: 'integer', minimum: 0, default: 0)), + new OA\Parameter(name: 'take', in: 'query', schema: new OA\Schema(type: 'integer', minimum: 1, default: 10)), + ], + responses: [ + new OA\Response(response: 200, description: 'List of database deployments'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] +)] +public function get_database_deployments(Request $request) +{ + $request->validate([ + 'skip' => ['nullable', 'integer', 'min:0'], + 'take' => ['nullable', 'integer', 'min:1'], + ]); + + $database_uuid = $request->route('uuid', null); + $skip = $request->get('skip', 0); + $take = $request->get('take', 10); + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Find database across all types + $database = getResourceByUuid($database_uuid, $teamId); + + if (!$database || !method_exists($database, 'deployments')) { + return response()->json(['message' => 'Database not found'], 404); + } + + $this->authorize('view', $database); + + $deployments = $database->deployments($skip, $take); + + return response()->json(serializeApiResponse($deployments)); +} +``` + +#### Endpoint 4: Get Database Deployment by UUID + +```php +#[OA\Get( + summary: 'Get database deployment', + description: 'Get a specific database deployment by deployment UUID', + path: '/deployments/databases/deployment/{uuid}', + operationId: 'get-database-deployment-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Deployments'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Database deployment details'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] +)] +public function database_deployment_by_uuid(Request $request) +{ + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + if (!$uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + + $deployment = DatabaseDeploymentQueue::where('deployment_uuid', $uuid)->first(); + if (!$deployment) { + return response()->json(['message' => 'Deployment not found.'], 404); + } + + // Authorization check via database + $database = $deployment->database; + if (!$database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + return response()->json($this->removeSensitiveData($deployment)); +} +``` + +#### Update: removeSensitiveData() method + +```php +private function removeSensitiveData($deployment) +{ + if (request()->attributes->get('can_read_sensitive', false) === false) { + $deployment->makeHidden(['logs']); + } + return serializeApiResponse($deployment); +} +``` + +**Note:** Already works for ServiceDeploymentQueue and DatabaseDeploymentQueue due to duck typing + +#### Update: deploy_resource() method + +**Before:** +```php +case Service::class: + StartService::run($resource); + $message = "Service {$resource->name} started. It could take a while, be patient."; + break; + +default: // Database + StartDatabase::dispatch($resource); + $message = "Database {$resource->name} started."; + break; +``` + +**After:** +```php +case Service::class: + $this->authorize('deploy', $resource); + $deployment_uuid = new Cuid2; + // StartService now handles deployment queue creation internally + StartService::run($resource); + $message = "Service {$resource->name} deployment started."; + break; + +default: // Database + $this->authorize('manage', $resource); + $deployment_uuid = new Cuid2; + // Start actions now handle deployment queue creation internally + StartDatabase::dispatch($resource); + $message = "Database {$resource->name} deployment started."; + break; +``` + +**Note:** deployment_uuid is now created inside actions, so API just returns message. If we want to return UUID to API, actions need to return deployment object. + +--- + +### Phase 8: Model Relationships (2 model updates) + +#### Update 1: Service Model + +**File:** `app/Models/Service.php` + +**Add Method:** +```php +/** + * Get deployment history for this service + */ +public function deployments(int $skip = 0, int $take = 10) +{ + return ServiceDeploymentQueue::where('service_id', $this->id) + ->orderBy('created_at', 'desc') + ->skip($skip) + ->take($take) + ->get(); +} + +/** + * Get latest deployment + */ +public function latestDeployment() +{ + return ServiceDeploymentQueue::where('service_id', $this->id) + ->orderBy('created_at', 'desc') + ->first(); +} +``` + +--- + +#### Update 2: All Standalone Database Models (9 files) + +**Files:** +- `app/Models/StandalonePostgresql.php` +- `app/Models/StandaloneRedis.php` +- `app/Models/StandaloneMongodb.php` +- `app/Models/StandaloneMysql.php` +- `app/Models/StandaloneMariadb.php` +- `app/Models/StandaloneKeydb.php` +- `app/Models/StandaloneDragonfly.php` +- `app/Models/StandaloneClickhouse.php` + +**Add Methods to Each:** +```php +/** + * Get deployment history for this database + */ +public function deployments(int $skip = 0, int $take = 10) +{ + return DatabaseDeploymentQueue::where('database_id', $this->id) + ->where('database_type', $this->getMorphClass()) + ->orderBy('created_at', 'desc') + ->skip($skip) + ->take($take) + ->get(); +} + +/** + * Get latest deployment + */ +public function latestDeployment() +{ + return DatabaseDeploymentQueue::where('database_id', $this->id) + ->where('database_type', $this->getMorphClass()) + ->orderBy('created_at', 'desc') + ->first(); +} +``` + +--- + +### Phase 9: Routes (4 new routes) + +**File:** `routes/api.php` + +**Add Routes:** +```php +Route::middleware(['auth:sanctum'])->group(function () { + // Existing routes... + + // Service deployment routes + Route::get('/deployments/services/{uuid}', [DeployController::class, 'get_service_deployments']) + ->name('deployments.services.list'); + Route::get('/deployments/services/deployment/{uuid}', [DeployController::class, 'service_deployment_by_uuid']) + ->name('deployments.services.show'); + + // Database deployment routes + Route::get('/deployments/databases/{uuid}', [DeployController::class, 'get_database_deployments']) + ->name('deployments.databases.list'); + Route::get('/deployments/databases/deployment/{uuid}', [DeployController::class, 'database_deployment_by_uuid']) + ->name('deployments.databases.show'); +}); +``` + +--- + +### Phase 10: Policies & Authorization (Optional - If needed) + +**Service Policy:** `app/Policies/ServicePolicy.php` +- May need to add `viewDeployment` and `viewDeployments` methods if they don't exist +- Check existing `view` gate - it should cover deployment viewing + +**Database Policies:** +- Each StandaloneDatabase type may have its own policy +- Verify `view` gate exists and covers deployment history access + +**Action Required:** Review existing policies during implementation. May not need changes if `view` gate is sufficient. + +--- + +## Testing Strategy + +### Unit Tests (Run outside Docker: `./vendor/bin/pest tests/Unit`) + +#### Test 1: ServiceDeploymentQueue Unit Test + +**File:** `tests/Unit/Models/ServiceDeploymentQueueTest.php` + +```php +create([ + 'logs' => null, + ]); + + $deployment->addLogEntry('Test message', 'stdout', false); + + expect($deployment->fresh()->logs)->not->toBeNull(); + + $logs = json_decode($deployment->fresh()->logs, true); + expect($logs)->toHaveCount(1); + expect($logs[0])->toHaveKeys(['command', 'output', 'type', 'timestamp', 'hidden', 'batch', 'order']); + expect($logs[0]['output'])->toBe('Test message'); + expect($logs[0]['type'])->toBe('stdout'); +}); + +it('redacts sensitive environment variables in logs', function () { + $service = Mockery::mock(Service::class); + $envVar = new \StdClass(); + $envVar->is_shown_once = true; + $envVar->key = 'SECRET_KEY'; + $envVar->real_value = 'super-secret-value'; + + $service->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn(collect([$envVar])); + + $deployment = ServiceDeploymentQueue::factory()->create(); + $deployment->setRelation('service', $service); + + $deployment->addLogEntry('Deploying with super-secret-value in logs', 'stdout'); + + $logs = json_decode($deployment->fresh()->logs, true); + expect($logs[0]['output'])->toContain(REDACTED); + expect($logs[0]['output'])->not->toContain('super-secret-value'); +}); + +it('sets status correctly', function () { + $deployment = ServiceDeploymentQueue::factory()->create(['status' => 'queued']); + + $deployment->setStatus('in_progress'); + expect($deployment->fresh()->status)->toBe('in_progress'); + + $deployment->setStatus('finished'); + expect($deployment->fresh()->status)->toBe('finished'); +}); +``` + +#### Test 2: DatabaseDeploymentQueue Unit Test + +**File:** `tests/Unit/Models/DatabaseDeploymentQueueTest.php` + +```php +create([ + 'logs' => null, + ]); + + $deployment->addLogEntry('Starting database', 'stdout', false); + + $logs = json_decode($deployment->fresh()->logs, true); + expect($logs)->toHaveCount(1); + expect($logs[0]['output'])->toBe('Starting database'); +}); + +it('redacts database credentials in logs', function () { + $database = Mockery::mock(StandalonePostgresql::class); + $database->shouldReceive('getAttribute') + ->with('postgres_password') + ->andReturn('db-password-123'); + $database->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn(collect([])); + $database->shouldReceive('getMorphClass') + ->andReturn(StandalonePostgresql::class); + + $deployment = DatabaseDeploymentQueue::factory()->create([ + 'database_type' => StandalonePostgresql::class, + ]); + $deployment->setRelation('database', $database); + + $deployment->addLogEntry('Connecting with password db-password-123', 'stdout'); + + $logs = json_decode($deployment->fresh()->logs, true); + expect($logs[0]['output'])->toContain(REDACTED); + expect($logs[0]['output'])->not->toContain('db-password-123'); +}); +``` + +--- + +### Feature Tests (Run inside Docker: `docker exec coolify php artisan test`) + +#### Test 3: Service Deployment Integration Test + +**File:** `tests/Feature/ServiceDeploymentTest.php` + +```php +create(); + + // Mock remote_process to prevent actual SSH + // (Implementation depends on existing test patterns) + + StartService::run($service); + + $deployment = ServiceDeploymentQueue::where('service_id', $service->id)->first(); + expect($deployment)->not->toBeNull(); + expect($deployment->service_name)->toBe($service->name); + expect($deployment->status)->toBe('in_progress'); +}); + +it('tracks multiple deployments for same service', function () { + $service = Service::factory()->create(); + + StartService::run($service); + StartService::run($service); + + $deployments = ServiceDeploymentQueue::where('service_id', $service->id)->get(); + expect($deployments)->toHaveCount(2); +}); +``` + +#### Test 4: Database Deployment Integration Test + +**File:** `tests/Feature/DatabaseDeploymentTest.php` + +```php +create(); + + // Mock remote_process + + StartPostgresql::run($database); + + $deployment = DatabaseDeploymentQueue::where('database_id', $database->id) + ->where('database_type', StandalonePostgresql::class) + ->first(); + + expect($deployment)->not->toBeNull(); + expect($deployment->database_name)->toBe($database->name); +}); + +// Repeat for other database types... +``` + +#### Test 5: API Endpoint Tests + +**File:** `tests/Feature/Api/DeploymentApiTest.php` + +```php +create(); + $service = Service::factory()->create([ + 'environment_id' => /* setup team/project/env */ + ]); + + ServiceDeploymentQueue::factory()->count(3)->create([ + 'service_id' => $service->id, + ]); + + $response = $this->actingAs($user) + ->getJson("/api/deployments/services/{$service->uuid}"); + + $response->assertSuccessful(); + $response->assertJsonCount(3); +}); + +it('requires authentication for service deployments', function () { + $service = Service::factory()->create(); + + $response = $this->getJson("/api/deployments/services/{$service->uuid}"); + + $response->assertUnauthorized(); +}); + +// Repeat for database endpoints... +``` + +--- + +## Rollout Plan + +### Phase Order (Safest to Riskiest) + +| Phase | Risk | Can Break Production? | Rollback Strategy | +|-------|------|----------------------|-------------------| +| 1. Schema | Low | No (new tables) | Drop tables | +| 2. Models | Low | No (unused code) | Remove files | +| 3. Enums | Low | No (unused code) | Remove files | +| 4. Helpers | Low | No (unused code) | Remove functions | +| 5. Actions | **HIGH** | **YES** | Revert to old actions | +| 6. Remote Process | **CRITICAL** | **YES** | Revert changes | +| 7. API | Medium | No (new endpoints) | Remove routes | +| 8. Relationships | Low | No (new methods) | Remove methods | +| 9. UI | Low | No (optional) | Remove components | +| 10. Policies | Low | Maybe (if breaking existing) | Revert gates | + +### Recommended Rollout Strategy + +**Week 1: Foundation (No Risk)** +- Complete Phases 1-4 +- Write and run all unit tests +- Verify migrations work in dev/staging + +**Week 2: Critical Changes (High Risk)** +- Complete Phase 5 (Actions) for **Services only** +- Complete Phase 6 (Remote Process handler) for Services +- Test extensively in staging +- Monitor for errors + +**Week 3: Database Support** +- Extend Phase 5 to all 9 database types +- Update Phase 6 for database support +- Test each database type individually + +**Week 4: API & Polish** +- Complete Phases 7-10 +- Feature tests +- API documentation +- User-facing features (if any) + +### Testing Checkpoints + +**After Phase 4:** +- ✅ Migrations apply cleanly +- ✅ Models instantiate without errors +- ✅ Unit tests pass + +**After Phase 5 (Services):** +- ✅ Service start creates deployment queue +- ✅ Service logs stream to deployment queue +- ✅ Service deployments appear in database +- ✅ No disruption to existing service starts + +**After Phase 5 (Databases):** +- ✅ Each database type creates deployment queue +- ✅ Database logs stream correctly +- ✅ No errors on database start + +**After Phase 7:** +- ✅ API endpoints return correct data +- ✅ Authorization works correctly +- ✅ Sensitive data is redacted + +--- + +## Known Risks & Mitigation + +### Risk 1: Breaking Existing Deployments +**Probability:** Medium +**Impact:** Critical + +**Mitigation:** +- Test exhaustively in staging before production +- Deploy during low-traffic window +- Have rollback plan ready (git revert + migration rollback) +- Monitor error logs closely after deploy + +### Risk 2: Database Performance Impact +**Probability:** Low +**Impact:** Medium + +**Details:** Each deployment now writes logs to DB multiple times (via `addLogEntry()`) + +**Mitigation:** +- Use `saveQuietly()` to avoid triggering events +- JSON column is indexed for fast retrieval +- Logs are text (compressed well by Postgres) +- Add monitoring for slow queries + +### Risk 3: Disk Space Growth +**Probability:** Medium (long-term) +**Impact:** Low + +**Details:** Deployment logs accumulate over time + +**Mitigation:** +- Implement log retention policy (delete deployments older than X days/months) +- Add background job to prune old deployment records +- Monitor disk usage trends + +### Risk 4: Polymorphic Relationship Complexity +**Probability:** Low +**Impact:** Low + +**Details:** DatabaseDeploymentQueue uses polymorphic relationship (9 database types) + +**Mitigation:** +- Thorough testing of each database type +- Composite indexes on (database_id, database_type) +- Clear documentation of relationship structure + +### Risk 5: Remote Process Integration +**Probability:** High +**Impact:** Critical + +**Details:** `PrepareCoolifyTask` is core to all deployments. Changes here affect everything. + +**Mitigation:** +- Review `PrepareCoolifyTask` code in detail before changes +- Add type checks (`instanceof`) to avoid breaking existing logic +- Extensive testing of application deployments after changes +- Keep changes minimal and focused + +--- + +## Migration Strategy for Existing Data + +**Q: What about existing services/databases that have been deployed before?** + +**A:** No migration needed. This is a **new feature**, not a data migration. + +- Services/databases deployed before this change won't have history +- New deployments (after feature is live) will be tracked +- This is acceptable - deployment history starts "now" + +**Alternative (if history is critical):** +- Could create fake deployment records for currently running resources +- Not recommended - logs don't exist, would be misleading + +--- + +## Performance Considerations + +### Database Writes During Deployment + +**Current:** ~1 write per deployment (Activity log, TTL-based) + +**New:** ~1 write per deployment + N writes for log entries +- Application deployments: ~50-200 log entries +- Service deployments: ~10-30 log entries +- Database deployments: ~5-15 log entries + +**Impact:** Minimal +- Writes are async (queued) +- Postgres handles small JSON updates efficiently +- `saveQuietly()` skips event dispatching overhead + +### Query Performance + +**Critical Queries:** +- "Get deployment history for service/database" - indexed on (resource_id, status, created_at) +- "Get deployment by UUID" - unique index on deployment_uuid +- "Get all in-progress deployments" - composite index on (server_id, status, created_at) + +**Expected Performance:** +- < 10ms for single deployment lookup +- < 50ms for paginated history (10 records) +- < 100ms for server-wide deployment status + +--- + +## Storage Estimates + +**Per Deployment:** +- Metadata: ~500 bytes +- Logs (avg): ~50KB (application), ~10KB (service), ~5KB (database) + +**1000 deployments/day:** +- Services: ~10MB/day = ~300MB/month +- Databases: ~5MB/day = ~150MB/month +- Total: ~450MB/month (highly compressible) + +**Retention Policy Recommendation:** +- Keep all deployments for 30 days +- Keep successful deployments for 90 days +- Keep failed deployments for 180 days (for debugging) + +--- + +## Alternative Approaches Considered + +### Option 1: Unified Resource Deployments Table + +**Schema:** +```sql +CREATE TABLE resource_deployments ( + id BIGINT PRIMARY KEY, + deployable_id INT, + deployable_type VARCHAR(255), -- App\Models\Service, App\Models\StandalonePostgresql, etc. + deployment_uuid VARCHAR(255) UNIQUE, + -- ... rest of fields + INDEX(deployable_id, deployable_type) +); +``` + +**Pros:** +- Single model to maintain +- DRY (Don't Repeat Yourself) +- Easier to query "all deployments across all resources" + +**Cons:** +- Polymorphic queries are slower +- No foreign key constraints +- Different resources have different deployment attributes +- Harder to optimize indexes per resource type +- More complex to reason about + +**Decision:** Rejected - Separate tables provide better type safety and performance + +--- + +### Option 2: Reuse Activity Log (Spatie) + +**Approach:** Don't create deployment queue tables. Use existing Activity log with longer TTL. + +**Pros:** +- Zero new code +- Activity log already stores logs + +**Cons:** +- Activity log is ephemeral (not designed for permanent history) +- No structured deployment metadata (status, UUIDs, etc.) +- Would need to change Activity TTL globally (affects all activities) +- Mixing concerns (Activity = audit log, Deployment = business logic) + +**Decision:** Rejected - Activity log and deployment history serve different purposes + +--- + +### Option 3: External Logging Service + +**Approach:** Stream logs to external service (S3, CloudWatch, etc.) + +**Pros:** +- Offload storage from main database +- Better for very large log volumes + +**Cons:** +- Additional infrastructure complexity +- Requires external dependencies +- Harder to query deployment history +- Not consistent with application deployment pattern + +**Decision:** Rejected - Keep it simple, follow existing patterns + +--- + +## Future Enhancements (Out of Scope) + +### 1. Deployment Queue System +- Like application deployments, queue service/database starts +- Respect server concurrent limits +- **Complexity:** High +- **Value:** Medium (services/databases deploy fast, queueing less critical) + +### 2. UI for Deployment History +- Livewire components to view past deployments +- Similar to application deployment history page +- **Complexity:** Medium +- **Value:** High (nice-to-have, not critical for first release) + +### 3. Deployment Comparison +- Diff between two deployments (config changes) +- **Complexity:** High +- **Value:** Low + +### 4. Deployment Rollback +- Roll back service/database to previous deployment +- **Complexity:** Very High (databases especially risky) +- **Value:** Medium + +### 5. Deployment Notifications +- Notify on service/database deployment success/failure +- **Complexity:** Low +- **Value:** Medium + +--- + +## Success Criteria + +### Minimum Viable Product (MVP) + +✅ Service deployments create deployment queue records +✅ Database deployments (all 9 types) create deployment queue records +✅ Logs stream to deployment queue during deployment +✅ Deployment status updates (in_progress → finished/failed) +✅ API endpoints to retrieve deployment history +✅ Sensitive data redaction in logs +✅ No disruption to existing application deployments +✅ All unit and feature tests pass + +### Nice-to-Have (Post-MVP) + +⚪ UI components for viewing deployment history +⚪ Deployment notifications +⚪ Log retention policy job +⚪ Deployment statistics/analytics + +--- + +## Questions to Resolve Before Implementation + +1. **Should we queue service/database starts (like applications)?** + - Current: Services/databases start immediately + - With queue: Respect server concurrent limits, better for cloud instance + - **Recommendation:** Start without queue, add later if needed + +2. **Should API deploy endpoints return deployment_uuid for services/databases?** + - Current: Application deploys return deployment_uuid + - Proposed: Services/databases should too + - **Recommendation:** Yes, for consistency. Requires actions to return deployment object. + +3. **What's the log retention policy?** + - **Recommendation:** 90 days for all, with background job to prune + +4. **Do we need UI in first release?** + - **Recommendation:** No, API is sufficient. Add UI iteratively. + +5. **Should we implement deployment cancellation?** + - Applications support cancellation + - **Recommendation:** Not in MVP, add later if requested + +--- + +## Implementation Checklist + +### Pre-Implementation +- [ ] Review this plan with team +- [ ] Get approval on architectural decisions +- [ ] Resolve open questions +- [ ] Set up staging environment for testing + +### Phase 1: Schema +- [ ] Create `create_service_deployment_queues_table` migration +- [ ] Create `create_database_deployment_queues_table` migration +- [ ] Create index optimization migration +- [ ] Test migrations in dev +- [ ] Run migrations in staging + +### Phase 2: Models +- [ ] Create `ServiceDeploymentQueue` model +- [ ] Create `DatabaseDeploymentQueue` model +- [ ] Add `$fillable`, `$guarded` properties +- [ ] Implement `addLogEntry()`, `setStatus()`, `getOutput()` methods +- [ ] Implement `redactSensitiveInfo()` methods +- [ ] Add OpenAPI schemas + +### Phase 3: Enums +- [ ] Create `ServiceDeploymentStatus` enum +- [ ] Create `DatabaseDeploymentStatus` enum + +### Phase 4: Helpers +- [ ] Add `queue_service_deployment()` to `bootstrap/helpers/services.php` +- [ ] Add `queue_database_deployment()` to `bootstrap/helpers/databases.php` +- [ ] Test helpers in Tinker + +### Phase 5: Actions +- [ ] Update `StartService` action +- [ ] Update `StartPostgresql` action +- [ ] Update `StartRedis` action +- [ ] Update `StartMongodb` action +- [ ] Update `StartMysql` action +- [ ] Update `StartMariadb` action +- [ ] Update `StartKeydb` action +- [ ] Update `StartDragonfly` action +- [ ] Update `StartClickhouse` action +- [ ] Test each action in staging + +### Phase 6: Remote Process +- [ ] Review `PrepareCoolifyTask` code +- [ ] Add type checks for ServiceDeploymentQueue +- [ ] Add type checks for DatabaseDeploymentQueue +- [ ] Add `addLogEntry()` calls +- [ ] Add status update logic +- [ ] Test with application deployments (ensure no regression) +- [ ] Test with service deployments +- [ ] Test with database deployments + +### Phase 7: API +- [ ] Add `get_service_deployments()` endpoint +- [ ] Add `service_deployment_by_uuid()` endpoint +- [ ] Add `get_database_deployments()` endpoint +- [ ] Add `database_deployment_by_uuid()` endpoint +- [ ] Update `deploy_resource()` to return deployment_uuid +- [ ] Update `removeSensitiveData()` if needed +- [ ] Add routes to `api.php` +- [ ] Test endpoints with Postman/curl + +### Phase 8: Relationships +- [ ] Add `deployments()` method to `Service` model +- [ ] Add `latestDeployment()` method to `Service` model +- [ ] Add `deployments()` method to all 9 Standalone database models +- [ ] Add `latestDeployment()` method to all 9 Standalone database models + +### Phase 9: Tests +- [ ] Write `ServiceDeploymentQueueTest` (unit) +- [ ] Write `DatabaseDeploymentQueueTest` (unit) +- [ ] Write `ServiceDeploymentTest` (feature) +- [ ] Write `DatabaseDeploymentTest` (feature) +- [ ] Write `DeploymentApiTest` (feature) +- [ ] Run all tests, ensure passing +- [ ] Run full test suite, ensure no regressions + +### Phase 10: Documentation +- [ ] Update API documentation +- [ ] Update CLAUDE.md if needed +- [ ] Add code comments for complex sections + +### Deployment +- [ ] Create PR with all changes +- [ ] Code review +- [ ] Test in staging (full regression suite) +- [ ] Deploy to production during low-traffic window +- [ ] Monitor error logs for 24 hours +- [ ] Verify deployments are being tracked + +### Post-Deployment +- [ ] Monitor disk usage trends +- [ ] Monitor query performance +- [ ] Gather user feedback +- [ ] Plan UI implementation (if needed) +- [ ] Plan log retention job + +--- + +## Contact & Support + +**Implementation Lead:** [Your Name] +**Reviewer:** [Reviewer Name] +**Questions:** Reference this document or ask in #dev channel + +--- + +**Last Updated:** 2025-10-30 +**Status:** Planning Complete, Ready for Implementation +**Next Step:** Review plan with team, get approval, begin Phase 1 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"