Merge pull request #7053 from coollabsio/andrasbacsai/update-service-versions
Add artisan command to update service Docker image versions
This commit is contained in:
commit
c6ae6a6cd9
1 changed files with 791 additions and 0 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue