1010import os
1111import re
1212import sys
13+ from functools import total_ordering
1314
1415from invoke .exceptions import Exit
1516from invoke .tasks import task
1617
18+ from tasks .kind_node_image import get_github_rc_releases
19+
1720try :
1821 import requests
1922except ImportError :
3033E2E_YAML_PATH = ".gitlab/e2e/e2e.yml"
3134
3235
36+ @total_ordering
37+ class Version :
38+ """Kubernetes version with proper semantic version comparison.
39+
40+ Handles both final releases and RC (release candidate) versions:
41+ - v1.35.0-rc.1 < v1.35.0-rc.2 < v1.35.0
42+ """
43+
44+ def __init__ (self , major : int , minor : int , patch : int , rc : int | None = None ):
45+ self .major = major
46+ self .minor = minor
47+ self .patch = patch
48+ self .rc = rc # None for final releases, number for RC versions
49+
50+ def __eq__ (self , other ):
51+ if not isinstance (other , Version ):
52+ return NotImplemented
53+ return (self .major , self .minor , self .patch , self .rc ) == (
54+ other .major ,
55+ other .minor ,
56+ other .patch ,
57+ other .rc ,
58+ )
59+
60+ def __lt__ (self , other ):
61+ if not isinstance (other , Version ):
62+ return NotImplemented
63+
64+ # Compare major, minor, patch first
65+ # If they aren't equal, then return the tuple comparison
66+ if (self .major , self .minor , self .patch ) != (other .major , other .minor , other .patch ):
67+ return (self .major , self .minor , self .patch ) < (other .major , other .minor , other .patch )
68+
69+ # If base versions are equal, compare RC status
70+ # RC versions are less than final releases: v1.35.0-rc.1 < v1.35.0
71+ if self .rc is None :
72+ return False # self is final (>= any RC or final with same base version)
73+ return other .rc is None or self .rc < other .rc # self is RC, so less than final OR compare RC numbers
74+
75+ def __repr__ (self ):
76+ if self .rc is None :
77+ return f"Version({ self .major } .{ self .minor } .{ self .patch } )"
78+ return f"Version({ self .major } .{ self .minor } .{ self .patch } -rc.{ self .rc } )"
79+
80+
3381def _check_dependencies ():
3482 """Check if required dependencies are installed."""
3583 missing = []
@@ -45,14 +93,27 @@ def _check_dependencies():
4593 )
4694
4795
48- def _parse_version (version_str : str ) -> tuple [ int , int , int ] | None :
96+ def _parse_version (version_str : str ) -> Version | None :
4997 """
50- Parse a Kubernetes version string like 'v1.34.0' into a tuple (1, 34, 0).
98+ Parse a Kubernetes version string into a Version object.
99+
100+ Examples:
101+ 'v1.34.0' -> Version(1, 34, 0)
102+ 'v1.35.0-rc.1' -> Version(1, 35, 0, rc=1)
103+
51104 Returns None if the version string is invalid.
52105 """
106+ # Match a release version
53107 match = re .match (r'^v?(\d+)\.(\d+)\.(\d+)$' , version_str )
54108 if match :
55- return tuple (map (int , match .groups ()))
109+ major , minor , patch = map (int , match .groups ())
110+ return Version (major , minor , patch )
111+ # Match an RC version
112+ rc_match = re .match (r'^v?(\d+)\.(\d+)\.(\d+)\-rc\.(\d+)$' , version_str )
113+ if rc_match :
114+ major , minor , patch , rc = map (int , rc_match .groups ())
115+ return Version (major , minor , patch , rc = rc )
116+
56117 return None
57118
58119
@@ -92,11 +153,13 @@ def _get_latest_k8s_versions() -> dict[str, dict[str, str]]:
92153 Fetch and parse the latest Kubernetes version from Docker Hub.
93154 Returns a dictionary with only the single latest version.
94155 """
95- tags = _get_docker_hub_tags ()
96156
97157 # Filter for valid Kubernetes version tags
98158 version_tags = []
99- for tag in tags :
159+
160+ # Final release Kubernetes version tags
161+ _ = _get_docker_hub_tags () # TODO: add this back when testing of RCs is done
162+ for tag in []:
100163 tag_name = tag .get ('name' , '' )
101164 version = _parse_version (tag_name )
102165
@@ -105,13 +168,40 @@ def _get_latest_k8s_versions() -> dict[str, dict[str, str]]:
105168 if digest :
106169 version_tags .append ({'version' : version , 'tag' : tag_name , 'digest' : digest })
107170
171+ # RC Kubernetes version tags
172+ for tag in get_github_rc_releases ():
173+ tag_name = tag .get ('tag_name' , '' )
174+ tarball = tag .get ('tarball_url' )
175+ version = _parse_version (tag_name )
176+ if version and tag_name and tarball :
177+ # Hardcode 'rc' to True because get_github_rc_releases() only returns rc releases
178+ version_tags .append ({'version' : version , 'tag' : tag_name , 'rc' : True , 'tarball' : tarball })
179+
108180 # Sort by version (major, minor, patch)
109181 version_tags .sort (key = lambda x : x ['version' ], reverse = True )
110182
111183 # Return only the single latest version
112184 if version_tags :
113185 latest = version_tags [0 ]
114- return {latest ['tag' ]: {'digest' : latest ['digest' ], 'tag' : latest ['tag' ]}}
186+
187+ # Parse out the necessary fields
188+ tag = latest .get ('tag' )
189+ digest = latest .get ('digest' )
190+ rc = latest .get ('rc' )
191+ tarball = latest .get ('tarball' )
192+
193+ # Build return dictionary
194+ # Structure: {tag_name: {'tag': tag_name, 'digest': digest?, 'rc': bool?, 'tarball': url?}}
195+ # Final releases include 'digest', RC releases include 'rc' and 'tarball'
196+ if tag :
197+ result = {tag : {'tag' : tag }}
198+ if digest :
199+ result [tag ]['digest' ] = digest
200+ if rc :
201+ result [tag ]['rc' ] = rc
202+ if tarball :
203+ result [tag ]['tarball' ] = tarball
204+ return result
115205
116206 return {}
117207
@@ -140,7 +230,7 @@ def _find_new_versions(
140230 new_versions = {}
141231
142232 for version , data in current .items ():
143- if version not in previous or previous [version ][ 'digest' ] != data [ 'digest' ] :
233+ if version not in previous or previous [version ]. get ( 'digest' ) != data . get ( 'digest' ) :
144234 new_versions [version ] = data
145235
146236 return new_versions
@@ -333,7 +423,7 @@ def fetch_versions(_, output_file=VERSIONS_FILE):
333423 """
334424 _check_dependencies ()
335425
336- print ("Fetching latest Kubernetes version from Docker Hub..." )
426+ print ("Fetching latest Kubernetes version from Docker Hub and GitHub ..." )
337427 current_versions = _get_latest_k8s_versions ()
338428
339429 if not current_versions :
@@ -344,20 +434,24 @@ def fetch_versions(_, output_file=VERSIONS_FILE):
344434 # Show the latest version
345435 latest_version = list (current_versions .keys ())[0 ]
346436 latest_data = current_versions [latest_version ]
437+
347438 print (f"Latest Kubernetes version: { latest_version } " )
348- print (f" Digest: { latest_data ['digest' ]} " )
439+ if latest_data .get ('tarball' ):
440+ print (f" Tarball: { latest_data ['tarball' ]} " )
441+ else :
442+ print (f" Digest: { latest_data ['digest' ]} " )
349443
350444 # Load previous versions and compare
351445 previous_versions = _load_existing_versions (output_file )
352446 new_versions = _find_new_versions (current_versions , previous_versions )
353447
354448 if new_versions :
355- print ("\n New version found!" )
449+ print ("\n New version(s) found!" )
356450 for version , data in new_versions .items ():
357- print ( f" { version } : { data [ 'digest' ] } " )
358-
359- # Save current versions for next run
360- _save_versions ( current_versions , output_file )
451+ if data . get ( 'tarball' ):
452+ print ( f" { version } : { data [ 'tarball' ] } " )
453+ else :
454+ print ( f" { version } : { data [ 'digest' ] } " )
361455
362456 # Set GitHub Actions outputs
363457 _set_github_output ('has_new_versions' , 'true' )
@@ -408,3 +502,41 @@ def update_e2e_yaml(_, versions_file=VERSIONS_FILE):
408502 else :
409503 _set_github_output ('updated' , 'false' )
410504 print ("\n No updates made" )
505+
506+
507+ @task
508+ def save_versions (_ , versions , versions_file = VERSIONS_FILE ):
509+ """
510+ Save multiple Kubernetes versions to the versions file.
511+
512+ This task merges the provided versions with existing versions in the file,
513+ preserving existing entries and adding new ones.
514+
515+ Args:
516+ versions: JSON string or dict mapping version tags to version data
517+ (e.g., '{"v1.35.0": {"tag": "v1.35.0", "digest": "sha256:..."}}')
518+ versions_file: Path to the JSON file to store versions (default: k8s_versions.json)
519+ """
520+
521+ # Parse if it's a JSON string
522+ if isinstance (versions , str ):
523+ try :
524+ versions = json .loads (versions )
525+ except json .JSONDecodeError as e :
526+ raise Exit (f"Invalid JSON in versions argument: { e } " , code = 1 ) from e
527+
528+ # Load existing versions
529+ existing_versions = _load_existing_versions (versions_file )
530+
531+ # Safely append the passed in dictionary items to the version list
532+ for outer_tag , version in versions .items ():
533+ inner_tag = version .get ('tag' )
534+ digest = version .get ('digest' )
535+ if not inner_tag or not digest :
536+ print (f"Version { outer_tag } is missing required field tag or digest, skipping..." )
537+ continue
538+
539+ existing_versions [outer_tag ] = {'tag' : inner_tag , 'digest' : digest }
540+
541+ # Save to file
542+ _save_versions (existing_versions , versions_file )
0 commit comments