3434
3535import argparse
3636import json
37+ import subprocess
3738from urllib .parse import urlparse
3839import pytz
40+ import requests
3941import yaml
4042import collections
4143import datetime
@@ -59,6 +61,8 @@ CACHE_MAX_AGE_METADATA = 60 * 5
5961# is up to date.
6062SUPPORTED = ["amis" , "gcp" ]
6163UNSUPPORTED = ["aliyun" , "azure" , "ibmcloud" , "powervs" ]
64+ # list of known streams with containers
65+ STREAMS = {"next" , "testing" , "stable" , "next-devel" , "testing-devel" , "rawhide" , "branched" }
6266
6367
6468def parse_args ():
@@ -70,6 +74,8 @@ def parse_args():
7074 parser .add_argument ("--gcp-json-key" , help = "GCP Service Account JSON Auth" , default = os .environ .get ("GCP_JSON_AUTH" ))
7175 parser .add_argument ("--acl" , help = "ACL for objects" , action = 'store' , default = 'private' )
7276 parser .add_argument ("--aws-config-file" , default = os .environ .get ("AWS_CONFIG_FILE" ), help = "Path to AWS config file" )
77+ parser .add_argument ("--registry-auth-file" , default = os .environ .get ("REGISTRY_AUTH_FILE" ),
78+ help = "Path to docker registry auth file. Directly passed to skopeo." )
7379 return parser .parse_args ()
7480
7581
@@ -110,6 +116,12 @@ def main():
110116 builds = builds_json_data ["builds" ]
111117 pruned_build_ids = []
112118 images_to_keep = policy .get (stream , {}).get ("images-keep" , [])
119+ barrier_releases = set ()
120+ # Get the update graph for stable streams
121+ if stream in ['stable' , 'testing' , 'next' ]:
122+ update_graph = get_update_graph (stream )['releases' ]
123+ # Keep only the barrier releases
124+ barrier_releases = set ([release ["version" ] for release in update_graph if "barrier" in release ])
113125
114126 # Iterate through builds from oldest to newest
115127 for build in reversed (builds ):
@@ -125,7 +137,7 @@ def main():
125137 current_build = Build (id = build_id , images = images , arch = arch , meta_json = meta_json )
126138
127139 # Iterate over actions (policy types) to apply pruning
128- for action in ['cloud-uploads' , 'images' , 'build' ]:
140+ for action in ['cloud-uploads' , 'images' , 'build' , 'containers' ]:
129141 if action not in policy [stream ]:
130142 continue
131143 action_duration = convert_duration_to_days (policy [stream ][action ])
@@ -162,7 +174,24 @@ def main():
162174 case "build" :
163175 prune_build (s3_client , bucket , prefix , build_id , args .dry_run )
164176 pruned_build_ids .append (build_id )
165-
177+ case "containers" :
178+ # Only prune for x86_64 as it's required for all builds, covering all architectures.
179+ if arch == "x86_64" :
180+ if build_id in barrier_releases :
181+ print (f"Release { build_id } is a barrier release, keeping." )
182+ continue
183+ # Retrieve container tags excluding the stream name since it updates with each release.
184+ container_tags = get_container_tags (meta_json , exclude = [stream ])
185+ if container_tags :
186+ containers_config = {
187+ "dry_run" : args .dry_run ,
188+ "repository_url" : container_tags .get ("repository_url" , "" ),
189+ "registry_auth_file" : args .registry_auth_file ,
190+ }
191+ for tag in container_tags ["tags" ]:
192+ prune_container (tag , containers_config )
193+ else :
194+ print (f"No container tags to prune for build { build_id } on architecture { arch } ." )
166195 # Update policy-cleanup after pruning actions for the architecture
167196 policy_cleanup = build .setdefault ("policy-cleanup" , {})
168197 for action in policy [stream ].keys (): # Only update actions specified in policy[stream]
@@ -174,6 +203,9 @@ def main():
174203 if "images" not in policy_cleanup :
175204 policy_cleanup ["images" ] = True
176205 policy_cleanup ["images-kept" ] = images_to_keep
206+ case "containers" :
207+ if "containers" not in policy_cleanup :
208+ policy_cleanup ["containers" ] = True
177209
178210 if pruned_build_ids :
179211 if "tombstone-builds" not in builds_json_data :
@@ -414,5 +446,66 @@ def prune_build(s3_client, bucket, prefix, build_id, dry_run):
414446 raise Exception (f"Error pruning { build_id } : { e .response ['Error' ]['Message' ]} " )
415447
416448
449+ def get_container_tags (meta_json , exclude ):
450+ container_tags = {}
451+ base_oscontainer = meta_json .get ("base-oscontainer" )
452+ if base_oscontainer :
453+ tags = base_oscontainer .get ("tags" , [])
454+ # Exclude any tags requested by the caller to be excluded
455+ filtered_tags = [tag for tag in tags if tag not in exclude ]
456+ if filtered_tags :
457+ container_tags ["tags" ] = filtered_tags
458+ container_tags ["repository_url" ] = base_oscontainer .get ("image" )
459+ return container_tags
460+
461+
462+ def prune_container (tag , containers_config ):
463+ if containers_config ["dry_run" ]:
464+ print (f"Would prune image { containers_config ['repository_url' ]} :{ tag } " )
465+ else :
466+ skopeo_delete (containers_config ["repository_url" ], tag , containers_config ["registry_auth_file" ])
467+
468+
469+ def get_update_graph (stream ):
470+ url = f"https://builds.coreos.fedoraproject.org/updates/{ stream } .json"
471+ r = requests .get (url , timeout = 5 )
472+ if r .status_code != 200 :
473+ raise Exception (f"Could not download update graph for { stream } . HTTP { r .status_code } " )
474+ return r .json ()
475+
476+
477+ def skopeo_inspect (repo , image , auth ):
478+ skopeo_args = ["skopeo" , "inspect" , "--no-tags" , "--retry-times=10" , f"docker://{ repo } :{ image } " ]
479+ if auth :
480+ skopeo_args .extend (["--authfile" , auth ])
481+ try :
482+ subprocess .check_output (skopeo_args , stderr = subprocess .STDOUT )
483+ return True # Inspection succeeded
484+ except subprocess .CalledProcessError as e :
485+ exit_code = e .returncode
486+ error_message = e .output .decode ("utf-8" )
487+
488+ # Exit code 2 indicates the image tag does not exist. We will consider it as pruned.
489+ if exit_code == 2 :
490+ print (f"Skipping deletion for { repo } :{ image } since the tag does not exist." )
491+ return False
492+ else :
493+ # Handle other types of errors
494+ raise Exception (f"Inspection failed for { repo } :{ image } with exit code { exit_code } : { error_message } " )
495+
496+
497+ def skopeo_delete (repo , image , auth ):
498+ if skopeo_inspect (repo , image , auth ): # Only proceed if inspection succeeds
499+ skopeo_args = ["skopeo" , "delete" , f"docker://{ repo } :{ image } " ]
500+ if auth :
501+ skopeo_args .extend (["--authfile" , auth ])
502+ try :
503+ subprocess .check_output (skopeo_args , stderr = subprocess .STDOUT )
504+ print (f"Image { repo } :{ image } deleted successfully." )
505+ except subprocess .CalledProcessError as e :
506+ # Throw an exception in case the delete command fail despite the image existing
507+ raise Exception ("An error occurred during deletion:" , e .output .decode ("utf-8" ))
508+
509+
417510if __name__ == "__main__" :
418511 main ()
0 commit comments