792 lines
28 KiB
PHP
792 lines
28 KiB
PHP
|
|
<?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.');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|