Merge branch 'v4.x' of github.com:thevinodpatidar/coolify into v4.x
This commit is contained in:
commit
622966b9d8
13 changed files with 954 additions and 29 deletions
791
app/Console/Commands/UpdateServiceVersions.php
Normal file
791
app/Console/Commands/UpdateServiceVersions.php
Normal file
|
|
@ -0,0 +1,791 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class UpdateServiceVersions extends Command
|
||||
{
|
||||
protected $signature = 'services:update-versions
|
||||
{--service= : Update specific service template}
|
||||
{--dry-run : Show what would be updated without making changes}
|
||||
{--registry= : Filter by registry (dockerhub, ghcr, quay, codeberg)}';
|
||||
|
||||
protected $description = 'Update service template files with latest Docker image versions from registries';
|
||||
|
||||
protected array $stats = [
|
||||
'total' => 0,
|
||||
'updated' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
protected array $registryCache = [];
|
||||
|
||||
protected array $majorVersionUpdates = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting service version update...');
|
||||
|
||||
$templateFiles = $this->getTemplateFiles();
|
||||
|
||||
$this->stats['total'] = count($templateFiles);
|
||||
|
||||
foreach ($templateFiles as $file) {
|
||||
$this->processTemplate($file);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->displayStats();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function getTemplateFiles(): array
|
||||
{
|
||||
$pattern = base_path('templates/compose/*.yaml');
|
||||
$files = glob($pattern);
|
||||
|
||||
if ($service = $this->option('service')) {
|
||||
$files = array_filter($files, fn ($file) => basename($file) === "$service.yaml");
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
protected function processTemplate(string $filePath): void
|
||||
{
|
||||
$filename = basename($filePath);
|
||||
$this->info("Processing: {$filename}");
|
||||
|
||||
try {
|
||||
$content = file_get_contents($filePath);
|
||||
$yaml = Yaml::parse($content);
|
||||
|
||||
if (! isset($yaml['services'])) {
|
||||
$this->warn(" No services found in {$filename}");
|
||||
$this->stats['skipped']++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = false;
|
||||
$updatedYaml = $yaml;
|
||||
|
||||
foreach ($yaml['services'] as $serviceName => $serviceConfig) {
|
||||
if (! isset($serviceConfig['image'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentImage = $serviceConfig['image'];
|
||||
|
||||
// Check if using 'latest' tag and log for manual review
|
||||
if (str_contains($currentImage, ':latest')) {
|
||||
$registryUrl = $this->getRegistryUrl($currentImage);
|
||||
$this->warn(" {$serviceName}: {$currentImage} (using 'latest' tag)");
|
||||
if ($registryUrl) {
|
||||
$this->line(" → Manual review: {$registryUrl}");
|
||||
}
|
||||
}
|
||||
|
||||
$latestVersion = $this->getLatestVersion($currentImage);
|
||||
|
||||
if ($latestVersion && $latestVersion !== $currentImage) {
|
||||
$this->line(" {$serviceName}: {$currentImage} → {$latestVersion}");
|
||||
$updatedYaml['services'][$serviceName]['image'] = $latestVersion;
|
||||
$updated = true;
|
||||
} else {
|
||||
$this->line(" {$serviceName}: {$currentImage} (up to date)");
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
if (! $this->option('dry-run')) {
|
||||
$this->updateYamlFile($filePath, $content, $updatedYaml);
|
||||
$this->stats['updated']++;
|
||||
} else {
|
||||
$this->warn(' [DRY RUN] Would update this file');
|
||||
$this->stats['updated']++;
|
||||
}
|
||||
} else {
|
||||
$this->stats['skipped']++;
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(" Failed: {$e->getMessage()}");
|
||||
$this->stats['failed']++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
protected function getLatestVersion(string $image): ?string
|
||||
{
|
||||
// Parse the image string
|
||||
[$repository, $currentTag] = $this->parseImage($image);
|
||||
|
||||
// Determine registry and fetch latest version
|
||||
$result = null;
|
||||
if (str_starts_with($repository, 'ghcr.io/')) {
|
||||
$result = $this->getGhcrLatestVersion($repository, $currentTag);
|
||||
} elseif (str_starts_with($repository, 'quay.io/')) {
|
||||
$result = $this->getQuayLatestVersion($repository, $currentTag);
|
||||
} elseif (str_starts_with($repository, 'codeberg.org/')) {
|
||||
$result = $this->getCodebergLatestVersion($repository, $currentTag);
|
||||
} elseif (str_starts_with($repository, 'lscr.io/')) {
|
||||
$result = $this->getDockerHubLatestVersion($repository, $currentTag);
|
||||
} elseif ($this->isCustomRegistry($repository)) {
|
||||
// Custom registries - skip for now, log warning
|
||||
$this->warn(" Skipping custom registry: {$repository}");
|
||||
$result = null;
|
||||
} else {
|
||||
// DockerHub (default registry - no prefix or docker.io/index.docker.io)
|
||||
$result = $this->getDockerHubLatestVersion($repository, $currentTag);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function isCustomRegistry(string $repository): bool
|
||||
{
|
||||
// List of custom/private registries that we can't query
|
||||
$customRegistries = [
|
||||
'docker.elastic.co/',
|
||||
'docker.n8n.io/',
|
||||
'docker.flipt.io/',
|
||||
'docker.getoutline.com/',
|
||||
'cr.weaviate.io/',
|
||||
'downloads.unstructured.io/',
|
||||
'budibase.docker.scarf.sh/',
|
||||
'calcom.docker.scarf.sh/',
|
||||
'code.forgejo.org/',
|
||||
'registry.supertokens.io/',
|
||||
'registry.rocket.chat/',
|
||||
'nabo.codimd.dev/',
|
||||
'gcr.io/',
|
||||
];
|
||||
|
||||
foreach ($customRegistries as $registry) {
|
||||
if (str_starts_with($repository, $registry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function getRegistryUrl(string $image): ?string
|
||||
{
|
||||
[$repository] = $this->parseImage($image);
|
||||
|
||||
// GitHub Container Registry
|
||||
if (str_starts_with($repository, 'ghcr.io/')) {
|
||||
$parts = explode('/', str_replace('ghcr.io/', '', $repository));
|
||||
if (count($parts) >= 2) {
|
||||
return "https://github.com/{$parts[0]}/{$parts[1]}/pkgs/container/{$parts[1]}";
|
||||
}
|
||||
}
|
||||
|
||||
// Quay.io
|
||||
if (str_starts_with($repository, 'quay.io/')) {
|
||||
$repo = str_replace('quay.io/', '', $repository);
|
||||
|
||||
return "https://quay.io/repository/{$repo}?tab=tags";
|
||||
}
|
||||
|
||||
// Codeberg
|
||||
if (str_starts_with($repository, 'codeberg.org/')) {
|
||||
$parts = explode('/', str_replace('codeberg.org/', '', $repository));
|
||||
if (count($parts) >= 2) {
|
||||
return "https://codeberg.org/{$parts[0]}/-/packages/container/{$parts[1]}";
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Hub
|
||||
$cleanRepo = str_replace(['index.docker.io/', 'docker.io/', 'lscr.io/'], '', $repository);
|
||||
if (! str_contains($cleanRepo, '/')) {
|
||||
// Official image
|
||||
return "https://hub.docker.com/_/{$cleanRepo}/tags";
|
||||
} else {
|
||||
// User/org image
|
||||
return "https://hub.docker.com/r/{$cleanRepo}/tags";
|
||||
}
|
||||
}
|
||||
|
||||
protected function parseImage(string $image): array
|
||||
{
|
||||
if (str_contains($image, ':')) {
|
||||
[$repo, $tag] = explode(':', $image, 2);
|
||||
} else {
|
||||
$repo = $image;
|
||||
$tag = 'latest';
|
||||
}
|
||||
|
||||
// Handle variables in tags
|
||||
if (str_contains($tag, '$')) {
|
||||
$tag = 'latest'; // Default to latest for variable tags
|
||||
}
|
||||
|
||||
return [$repo, $tag];
|
||||
}
|
||||
|
||||
protected function getDockerHubLatestVersion(string $repository, string $currentTag): ?string
|
||||
{
|
||||
try {
|
||||
// Check if we've already fetched tags for this repository
|
||||
if (! isset($this->registryCache[$repository.'_tags'])) {
|
||||
// Remove various registry prefixes
|
||||
$cleanRepo = $repository;
|
||||
$cleanRepo = str_replace('index.docker.io/', '', $cleanRepo);
|
||||
$cleanRepo = str_replace('docker.io/', '', $cleanRepo);
|
||||
$cleanRepo = str_replace('lscr.io/', '', $cleanRepo);
|
||||
|
||||
// For official images (no /) add library prefix
|
||||
if (! str_contains($cleanRepo, '/')) {
|
||||
$cleanRepo = "library/{$cleanRepo}";
|
||||
}
|
||||
|
||||
$url = "https://hub.docker.com/v2/repositories/{$cleanRepo}/tags";
|
||||
|
||||
$response = Http::timeout(10)->get($url, [
|
||||
'page_size' => 100,
|
||||
'ordering' => 'last_updated',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$tags = $data['results'] ?? [];
|
||||
|
||||
// Cache the tags for this repository
|
||||
$this->registryCache[$repository.'_tags'] = $tags;
|
||||
} else {
|
||||
$this->line(" [cached] Using cached tags for {$repository}");
|
||||
$tags = $this->registryCache[$repository.'_tags'];
|
||||
}
|
||||
|
||||
// Find the best matching tag
|
||||
return $this->findBestTag($tags, $currentTag, $repository);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" DockerHub API error for {$repository}: {$e->getMessage()}");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function findLatestTagDigest(array $tags, string $targetTag = 'latest'): ?string
|
||||
{
|
||||
// Find the digest/sha for the target tag (usually 'latest')
|
||||
foreach ($tags as $tag) {
|
||||
if ($tag['name'] === $targetTag) {
|
||||
return $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function findVersionTagsForDigest(array $tags, string $digest): array
|
||||
{
|
||||
// Find all semantic version tags that share the same digest
|
||||
$versionTags = [];
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$tagDigest = $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
|
||||
|
||||
if ($tagDigest === $digest) {
|
||||
$tagName = $tag['name'];
|
||||
// Only include semantic version tags
|
||||
if (preg_match('/^\d+\.\d+(\.\d+)?$/', $tagName)) {
|
||||
$versionTags[] = $tagName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $versionTags;
|
||||
}
|
||||
|
||||
protected function getGhcrLatestVersion(string $repository, string $currentTag): ?string
|
||||
{
|
||||
try {
|
||||
// GHCR doesn't have a public API for listing tags without auth
|
||||
// We'll try to fetch the package metadata via GitHub API
|
||||
$parts = explode('/', str_replace('ghcr.io/', '', $repository));
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$owner = $parts[0];
|
||||
$package = $parts[1];
|
||||
|
||||
// Try GitHub Container Registry API
|
||||
$url = "https://api.github.com/users/{$owner}/packages/container/{$package}/versions";
|
||||
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders([
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
])
|
||||
->get($url, ['per_page' => 100]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
// Most GHCR packages require authentication
|
||||
if ($currentTag === 'latest') {
|
||||
$this->warn(' ⚠ GHCR requires authentication - manual review needed');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$versions = $response->json();
|
||||
$tags = [];
|
||||
|
||||
// Build tags array with digest information
|
||||
foreach ($versions as $version) {
|
||||
$digest = $version['name'] ?? null; // This is the SHA digest
|
||||
|
||||
if (isset($version['metadata']['container']['tags'])) {
|
||||
foreach ($version['metadata']['container']['tags'] as $tag) {
|
||||
$tags[] = [
|
||||
'name' => $tag,
|
||||
'digest' => $digest,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->findBestTag($tags, $currentTag, $repository);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" GHCR API error for {$repository}: {$e->getMessage()}");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getQuayLatestVersion(string $repository, string $currentTag): ?string
|
||||
{
|
||||
try {
|
||||
// Check if we've already fetched tags for this repository
|
||||
if (! isset($this->registryCache[$repository.'_tags'])) {
|
||||
$cleanRepo = str_replace('quay.io/', '', $repository);
|
||||
|
||||
$url = "https://quay.io/api/v1/repository/{$cleanRepo}/tag/";
|
||||
|
||||
$response = Http::timeout(10)->get($url, ['limit' => 100]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$tags = array_map(fn ($tag) => ['name' => $tag['name']], $data['tags'] ?? []);
|
||||
|
||||
// Cache the tags for this repository
|
||||
$this->registryCache[$repository.'_tags'] = $tags;
|
||||
} else {
|
||||
$this->line(" [cached] Using cached tags for {$repository}");
|
||||
$tags = $this->registryCache[$repository.'_tags'];
|
||||
}
|
||||
|
||||
return $this->findBestTag($tags, $currentTag, $repository);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" Quay API error for {$repository}: {$e->getMessage()}");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getCodebergLatestVersion(string $repository, string $currentTag): ?string
|
||||
{
|
||||
try {
|
||||
// Check if we've already fetched tags for this repository
|
||||
if (! isset($this->registryCache[$repository.'_tags'])) {
|
||||
// Codeberg uses Forgejo/Gitea, which has a container registry API
|
||||
$cleanRepo = str_replace('codeberg.org/', '', $repository);
|
||||
$parts = explode('/', $cleanRepo);
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$owner = $parts[0];
|
||||
$package = $parts[1];
|
||||
|
||||
// Codeberg API endpoint for packages
|
||||
$url = "https://codeberg.org/api/packages/{$owner}/container/{$package}";
|
||||
|
||||
$response = Http::timeout(10)->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$tags = [];
|
||||
|
||||
if (isset($data['versions'])) {
|
||||
foreach ($data['versions'] as $version) {
|
||||
if (isset($version['name'])) {
|
||||
$tags[] = ['name' => $version['name']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the tags for this repository
|
||||
$this->registryCache[$repository.'_tags'] = $tags;
|
||||
} else {
|
||||
$this->line(" [cached] Using cached tags for {$repository}");
|
||||
$tags = $this->registryCache[$repository.'_tags'];
|
||||
}
|
||||
|
||||
return $this->findBestTag($tags, $currentTag, $repository);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" Codeberg API error for {$repository}: {$e->getMessage()}");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function findBestTag(array $tags, string $currentTag, string $repository): ?string
|
||||
{
|
||||
if (empty($tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If current tag is 'latest', find what version it actually points to
|
||||
if ($currentTag === 'latest') {
|
||||
// First, try to find the digest for 'latest' tag
|
||||
$latestDigest = $this->findLatestTagDigest($tags, 'latest');
|
||||
|
||||
if ($latestDigest) {
|
||||
// Find all semantic version tags that share the same digest
|
||||
$versionTags = $this->findVersionTagsForDigest($tags, $latestDigest);
|
||||
|
||||
if (! empty($versionTags)) {
|
||||
// Prefer shorter version tags (1.8 over 1.8.1)
|
||||
$bestVersion = $this->preferShorterVersion($versionTags);
|
||||
$this->info(" ✓ Found 'latest' points to: {$bestVersion}");
|
||||
|
||||
return $repository.':'.$bestVersion;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: get the latest semantic version available (prefer shorter)
|
||||
$semverTags = $this->filterSemanticVersionTags($tags);
|
||||
if (! empty($semverTags)) {
|
||||
$bestVersion = $this->preferShorterVersion($semverTags);
|
||||
|
||||
return $repository.':'.$bestVersion;
|
||||
}
|
||||
|
||||
// If no semantic versions found, keep 'latest'
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for major version updates for reporting
|
||||
$this->checkForMajorVersionUpdate($tags, $currentTag, $repository);
|
||||
|
||||
// If current tag is a major version (e.g., "8", "5", "16")
|
||||
if (preg_match('/^\d+$/', $currentTag)) {
|
||||
$majorVersion = (int) $currentTag;
|
||||
$matchingTags = array_filter($tags, function ($tag) use ($majorVersion) {
|
||||
$name = $tag['name'];
|
||||
|
||||
// Match tags that start with the major version
|
||||
return preg_match("/^{$majorVersion}(\.\d+)?(\.\d+)?$/", $name);
|
||||
});
|
||||
|
||||
if (! empty($matchingTags)) {
|
||||
$versions = array_column($matchingTags, 'name');
|
||||
$bestVersion = $this->preferShorterVersion($versions);
|
||||
if ($bestVersion !== $currentTag) {
|
||||
return $repository.':'.$bestVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current tag is date-based version (e.g., "2025.06.02-sha-xxx")
|
||||
if (preg_match('/^\d{4}\.\d{2}\.\d{2}/', $currentTag)) {
|
||||
// Get all date-based tags
|
||||
$dateTags = array_filter($tags, function ($tag) {
|
||||
return preg_match('/^\d{4}\.\d{2}\.\d{2}/', $tag['name']);
|
||||
});
|
||||
|
||||
if (! empty($dateTags)) {
|
||||
$versions = array_column($dateTags, 'name');
|
||||
$sorted = $this->sortSemanticVersions($versions);
|
||||
$latestDate = $sorted[0];
|
||||
|
||||
// Compare dates
|
||||
if ($latestDate !== $currentTag) {
|
||||
return $repository.':'.$latestDate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// If current tag is semantic version (e.g., "1.7.4", "8.0")
|
||||
if (preg_match('/^\d+\.\d+(\.\d+)?$/', $currentTag)) {
|
||||
$parts = explode('.', $currentTag);
|
||||
$majorMinor = $parts[0].'.'.$parts[1];
|
||||
|
||||
$matchingTags = array_filter($tags, function ($tag) use ($majorMinor) {
|
||||
$name = $tag['name'];
|
||||
|
||||
return str_starts_with($name, $majorMinor);
|
||||
});
|
||||
|
||||
if (! empty($matchingTags)) {
|
||||
$versions = array_column($matchingTags, 'name');
|
||||
$bestVersion = $this->preferShorterVersion($versions);
|
||||
if (version_compare($bestVersion, $currentTag, '>') || version_compare($bestVersion, $currentTag, '=')) {
|
||||
// Only update if it's newer or if we can simplify (1.8.1 -> 1.8)
|
||||
if ($bestVersion !== $currentTag) {
|
||||
return $repository.':'.$bestVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current tag is a named version (e.g., "stable")
|
||||
if (in_array($currentTag, ['stable', 'lts', 'edge'])) {
|
||||
// Check if the same tag exists in the list (it's up to date)
|
||||
$exists = array_filter($tags, fn ($tag) => $tag['name'] === $currentTag);
|
||||
if (! empty($exists)) {
|
||||
return null; // Tag exists and is current
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function filterSemanticVersionTags(array $tags): array
|
||||
{
|
||||
$semverTags = array_filter($tags, function ($tag) {
|
||||
$name = $tag['name'];
|
||||
|
||||
// Accept semantic versions (1.2.3, v1.2.3)
|
||||
if (preg_match('/^v?\d+\.\d+(\.\d+)?(\.\d+)?$/', $name)) {
|
||||
// Exclude versions with suffixes like -rc, -beta, -alpha
|
||||
if (preg_match('/-(rc|beta|alpha|dev|test|pre|snapshot)/i', $name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Accept date-based versions (2025.06.02, 2025.10.0, 2025.06.02-sha-xxx, RELEASE.2025-10-15T17-29-55Z)
|
||||
if (preg_match('/^\d{4}\.\d{2}\.(\d{2}|\d)/', $name) || preg_match('/^RELEASE\.\d{4}-\d{2}-\d{2}/', $name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return $this->sortSemanticVersions(array_column($semverTags, 'name'));
|
||||
}
|
||||
|
||||
protected function sortSemanticVersions(array $versions): array
|
||||
{
|
||||
usort($versions, function ($a, $b) {
|
||||
// Check if these are date-based versions (YYYY.MM.DD or YYYY.MM.D format)
|
||||
$isDateA = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $a, $matchesA);
|
||||
$isDateB = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $b, $matchesB);
|
||||
|
||||
if ($isDateA && $isDateB) {
|
||||
// Both are date-based (YYYY.MM.DD), compare as dates
|
||||
$dateA = $matchesA[1].$matchesA[2].str_pad($matchesA[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
|
||||
$dateB = $matchesB[1].$matchesB[2].str_pad($matchesB[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
|
||||
|
||||
return strcmp($dateB, $dateA); // Descending order (newest first)
|
||||
}
|
||||
|
||||
// Check if these are RELEASE date versions (RELEASE.YYYY-MM-DDTHH-MM-SSZ)
|
||||
$isReleaseA = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $a, $matchesA);
|
||||
$isReleaseB = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $b, $matchesB);
|
||||
|
||||
if ($isReleaseA && $isReleaseB) {
|
||||
// Both are RELEASE format, compare as datetime
|
||||
$dateTimeA = $matchesA[1].$matchesA[2].$matchesA[3].$matchesA[4].$matchesA[5].$matchesA[6]; // YYYYMMDDHHMMSS
|
||||
$dateTimeB = $matchesB[1].$matchesB[2].$matchesB[3].$matchesB[4].$matchesB[5].$matchesB[6]; // YYYYMMDDHHMMSS
|
||||
|
||||
return strcmp($dateTimeB, $dateTimeA); // Descending order (newest first)
|
||||
}
|
||||
|
||||
// Strip 'v' prefix for version comparison
|
||||
$cleanA = ltrim($a, 'v');
|
||||
$cleanB = ltrim($b, 'v');
|
||||
|
||||
// Fall back to semantic version comparison
|
||||
return version_compare($cleanB, $cleanA); // Descending order
|
||||
});
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
protected function preferShorterVersion(array $versions): string
|
||||
{
|
||||
if (empty($versions)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Sort by version (highest first)
|
||||
$sorted = $this->sortSemanticVersions($versions);
|
||||
$highest = $sorted[0];
|
||||
|
||||
// Parse the highest version
|
||||
$parts = explode('.', $highest);
|
||||
|
||||
// Look for shorter versions that match
|
||||
// Priority: major (8) > major.minor (8.0) > major.minor.patch (8.0.39)
|
||||
|
||||
// Try to find just major.minor (e.g., 1.8 instead of 1.8.1)
|
||||
if (count($parts) === 3) {
|
||||
$majorMinor = $parts[0].'.'.$parts[1];
|
||||
if (in_array($majorMinor, $versions)) {
|
||||
return $majorMinor;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find just major (e.g., 8 instead of 8.0.39)
|
||||
if (count($parts) >= 2) {
|
||||
$major = $parts[0];
|
||||
if (in_array($major, $versions)) {
|
||||
return $major;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the highest version we found
|
||||
return $highest;
|
||||
}
|
||||
|
||||
protected function updateYamlFile(string $filePath, string $originalContent, array $updatedYaml): void
|
||||
{
|
||||
// Preserve comments and formatting by updating the YAML content
|
||||
$lines = explode("\n", $originalContent);
|
||||
$updatedLines = [];
|
||||
$inServices = false;
|
||||
$currentService = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Detect if we're in the services section
|
||||
if (preg_match('/^services:/', $line)) {
|
||||
$inServices = true;
|
||||
$updatedLines[] = $line;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect service name (allow hyphens and underscores)
|
||||
if ($inServices && preg_match('/^ ([\w-]+):/', $line, $matches)) {
|
||||
$currentService = $matches[1];
|
||||
$updatedLines[] = $line;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update image line
|
||||
if ($currentService && preg_match('/^(\s+)image:\s*(.+)$/', $line, $matches)) {
|
||||
$indent = $matches[1];
|
||||
$newImage = $updatedYaml['services'][$currentService]['image'] ?? $matches[2];
|
||||
$updatedLines[] = "{$indent}image: {$newImage}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we hit a non-indented line, we're out of services
|
||||
if ($inServices && preg_match('/^\S/', $line) && ! preg_match('/^services:/', $line)) {
|
||||
$inServices = false;
|
||||
$currentService = null;
|
||||
}
|
||||
|
||||
$updatedLines[] = $line;
|
||||
}
|
||||
|
||||
file_put_contents($filePath, implode("\n", $updatedLines));
|
||||
}
|
||||
|
||||
protected function checkForMajorVersionUpdate(array $tags, string $currentTag, string $repository): void
|
||||
{
|
||||
// Only check semantic versions
|
||||
if (! preg_match('/^v?(\d+)\./', $currentTag, $currentMatches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentMajor = (int) $currentMatches[1];
|
||||
|
||||
// Get all semantic version tags
|
||||
$semverTags = $this->filterSemanticVersionTags($tags);
|
||||
|
||||
// Find the highest major version available
|
||||
$highestMajor = $currentMajor;
|
||||
foreach ($semverTags as $version) {
|
||||
if (preg_match('/^v?(\d+)\./', $version, $matches)) {
|
||||
$major = (int) $matches[1];
|
||||
if ($major > $highestMajor) {
|
||||
$highestMajor = $major;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a higher major version available, record it
|
||||
if ($highestMajor > $currentMajor) {
|
||||
$this->majorVersionUpdates[] = [
|
||||
'repository' => $repository,
|
||||
'current' => $currentTag,
|
||||
'current_major' => $currentMajor,
|
||||
'available_major' => $highestMajor,
|
||||
'registry_url' => $this->getRegistryUrl($repository.':'.$currentTag),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function displayStats(): void
|
||||
{
|
||||
$this->info('Summary:');
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total Templates', $this->stats['total']],
|
||||
['Updated', $this->stats['updated']],
|
||||
['Skipped (up to date)', $this->stats['skipped']],
|
||||
['Failed', $this->stats['failed']],
|
||||
]
|
||||
);
|
||||
|
||||
// Display major version updates if any
|
||||
if (! empty($this->majorVersionUpdates)) {
|
||||
$this->newLine();
|
||||
$this->warn('⚠ Services with available MAJOR version updates:');
|
||||
$this->newLine();
|
||||
|
||||
$tableData = [];
|
||||
foreach ($this->majorVersionUpdates as $update) {
|
||||
$tableData[] = [
|
||||
$update['repository'],
|
||||
"v{$update['current_major']}.x",
|
||||
"v{$update['available_major']}.x",
|
||||
$update['registry_url'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Repository', 'Current', 'Available', 'Registry URL'],
|
||||
$tableData
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->comment('💡 Major version updates may include breaking changes. Review before upgrading.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,60 @@ public function loadTokens()
|
|||
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
|
||||
}
|
||||
|
||||
public function validateToken(int $tokenId)
|
||||
{
|
||||
try {
|
||||
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
|
||||
$this->authorize('view', $token);
|
||||
|
||||
if ($token->provider === 'hetzner') {
|
||||
$isValid = $this->validateHetznerToken($token->token);
|
||||
if ($isValid) {
|
||||
$this->dispatch('success', 'Hetzner token is valid.');
|
||||
} else {
|
||||
$this->dispatch('error', 'Hetzner token validation failed. Please check the token.');
|
||||
}
|
||||
} elseif ($token->provider === 'digitalocean') {
|
||||
$isValid = $this->validateDigitalOceanToken($token->token);
|
||||
if ($isValid) {
|
||||
$this->dispatch('success', 'DigitalOcean token is valid.');
|
||||
} else {
|
||||
$this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
|
||||
}
|
||||
} else {
|
||||
$this->dispatch('error', 'Unknown provider.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateHetznerToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withToken($token)
|
||||
->timeout(10)
|
||||
->get('https://api.hetzner.cloud/v1/servers?per_page=1');
|
||||
|
||||
return $response->successful();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDigitalOceanToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withToken($token)
|
||||
->timeout(10)
|
||||
->get('https://api.digitalocean.com/v2/account');
|
||||
|
||||
return $response->successful();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId)
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -561,7 +561,12 @@ public function submit()
|
|||
$server->save();
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
// When in onboarding, use wire:navigate for proper modal handling
|
||||
// Complete the boarding when server is successfully created via Hetzner
|
||||
currentTeam()->update([
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
refreshSession();
|
||||
|
||||
return $this->redirect(route('server.show', $server->uuid));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,20 @@ protected static function booted(): void
|
|||
if ($applications_count > 0) {
|
||||
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
|
||||
}
|
||||
$github_app->privateKey()->delete();
|
||||
|
||||
$privateKey = $github_app->privateKey;
|
||||
if ($privateKey) {
|
||||
// Check if key is used by anything EXCEPT this GitHub app
|
||||
$isUsedElsewhere = $privateKey->servers()->exists()
|
||||
|| $privateKey->applications()->exists()
|
||||
|| $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists()
|
||||
|| $privateKey->gitlabApps()->exists();
|
||||
|
||||
if (! $isUsedElsewhere) {
|
||||
$privateKey->delete();
|
||||
} else {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
min="0"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
|
||||
type="number" min="0" step="0.0000001"
|
||||
type="number" min="0"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
min="0"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
|
||||
type="number" min="0" step="0.0000001"
|
||||
type="number" min="0"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@
|
|||
<x-forms.input required id="name" label="Token Name"
|
||||
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
|
||||
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
placeholder="Enter your API token" />
|
||||
|
||||
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Create an API token in the <a
|
||||
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}' target='_blank'
|
||||
class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choose
|
||||
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
|
||||
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choose
|
||||
Project → Security → API Tokens.
|
||||
@if ($provider === 'hetzner')
|
||||
<br><br>
|
||||
|
|
@ -28,7 +29,7 @@ class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choos
|
|||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
|
||||
and gives you €20)</span>
|
||||
and gives you €20)</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -49,7 +50,8 @@ class='underline dark:text-white'>Sign up here</a>
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
placeholder="Enter your API token" />
|
||||
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
|
||||
|
|
@ -60,7 +62,7 @@ class='underline dark:text-white'>Hetzner Console</a> → choose Project → Sec
|
|||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
|
||||
and gives you €20)</span>
|
||||
and gives you €20)</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,16 +20,24 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
|
|||
</div>
|
||||
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
|
||||
|
||||
@can('delete', $savedToken)
|
||||
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
|
||||
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
|
||||
'This cloud provider token will be permanently deleted.',
|
||||
'Any servers using this token will need to be reconfigured.',
|
||||
]"
|
||||
confirmationText="{{ $savedToken->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the token name below"
|
||||
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
|
||||
@endcan
|
||||
<div class="flex gap-2 pt-2">
|
||||
@can('view', $savedToken)
|
||||
<x-forms.button wire:click="validateToken({{ $savedToken->id }})" type="button">
|
||||
Validate Token
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
|
||||
@can('delete', $savedToken)
|
||||
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
|
||||
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
|
||||
'This cloud provider token will be permanently deleted.',
|
||||
'Any servers using this token will need to be reconfigured.',
|
||||
]"
|
||||
confirmationText="{{ $savedToken->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the token name below"
|
||||
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full gap-2 xl:flex-row">
|
||||
<x-forms.input required id="smtpHost" placeholder="smtp.mailgun.org" label="Host" />
|
||||
<x-forms.input required id="smtpPort" placeholder="587" label="Port" />
|
||||
<x-forms.input required id="smtpPort" type="number" placeholder="587" label="Port" />
|
||||
<x-forms.select required id="smtpEncryption" label="Encryption">
|
||||
<option value="starttls">StartTLS</option>
|
||||
<option value="tls">TLS/SSL</option>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Note: Full Livewire integration tests require database setup
|
||||
// These tests verify the SSH key merging logic and public_net configuration
|
||||
|
||||
|
|
@ -134,3 +140,49 @@
|
|||
expect($sshKeys)->toBe([123, 456, 789])
|
||||
->and(count($sshKeys))->toBe(3);
|
||||
});
|
||||
|
||||
describe('Boarding Flow Integration', function () {
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner that has boarding enabled
|
||||
$this->team = Team::factory()->create([
|
||||
'show_boarding' => true,
|
||||
]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Set current team and act as user
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('completes boarding when server is created from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component with from_onboarding flag
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
->set('from_onboarding', true);
|
||||
|
||||
// Verify the from_onboarding property is set
|
||||
expect($component->get('from_onboarding'))->toBeTrue();
|
||||
|
||||
// After successful server creation in the actual component,
|
||||
// the boarding should be marked as complete
|
||||
// Note: We can't fully test the createServer method without mocking Hetzner API
|
||||
// but we can verify the boarding completion logic is in place
|
||||
});
|
||||
|
||||
test('boarding flag remains unchanged when not from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component without from_onboarding flag (default false)
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('from_onboarding', false);
|
||||
|
||||
// Boarding should still be enabled since it wasn't created from onboarding
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue