66"""
77
88import argparse
9+ import datetime
10+ import functools
911import hashlib
12+ import json
1013import logging
1114import subprocess
1215import sys
16+ import urllib .request
1317from pathlib import Path
1418
1519log = logging .getLogger ("build-in-container" )
1620
1721IMAGE_REGISTRY = "ghcr.io/cfengine"
18- IMAGE_VERSION = "1"
19-
20- PLATFORMS = {
21- "ubuntu-20" : {
22- "image_tag" : f"cfengine-builder-ubuntu-20:{ IMAGE_VERSION } " ,
23- "base_image" : "ubuntu:20.04" ,
24- "dockerfile" : "Dockerfile.debian" ,
25- "extra_build_args" : {"NCURSES_PKGS" : "libncurses5 libncurses5-dev" },
26- },
27- "ubuntu-22" : {
28- "image_tag" : f"cfengine-builder-ubuntu-22:{ IMAGE_VERSION } " ,
29- "base_image" : "ubuntu:22.04" ,
30- "dockerfile" : "Dockerfile.debian" ,
31- "extra_build_args" : {},
32- },
33- "ubuntu-24" : {
34- "image_tag" : f"cfengine-builder-ubuntu-24:{ IMAGE_VERSION } " ,
35- "base_image" : "ubuntu:24.04" ,
36- "dockerfile" : "Dockerfile.debian" ,
37- "extra_build_args" : {},
38- },
39- "debian-11" : {
40- "image_tag" : f"cfengine-builder-debian-11:{ IMAGE_VERSION } " ,
41- "base_image" : "debian:11" ,
42- "dockerfile" : "Dockerfile.debian" ,
43- "extra_build_args" : {},
44- },
45- "debian-12" : {
46- "image_tag" : f"cfengine-builder-debian-12:{ IMAGE_VERSION } " ,
47- "base_image" : "debian:12" ,
48- "dockerfile" : "Dockerfile.debian" ,
49- "extra_build_args" : {},
50- },
51- }
22+
23+
24+ @functools .cache
25+ def get_config ():
26+ """Load and cache platform configuration from platforms.json."""
27+ config_path = Path (__file__ ).resolve ().parent / "platforms.json"
28+ return json .loads (config_path .read_text ())
5229
5330
5431def detect_source_dir ():
@@ -88,7 +65,7 @@ def image_needs_rebuild(image_tag, current_hash):
8865
8966def build_image (platform_name , platform_config , script_dir , rebuild = False ):
9067 """Build the Docker image for the given platform."""
91- image_tag = platform_config ["image_tag" ]
68+ image_tag = f" { platform_config ['image_name' ] } : { platform_config [ 'image_version' ] } "
9269 dockerfile_name = platform_config ["dockerfile" ]
9370 dockerfile_path = script_dir / "container" / dockerfile_name
9471 current_hash = dockerfile_hash (dockerfile_path )
@@ -104,7 +81,7 @@ def build_image(platform_name, platform_config, script_dir, rebuild=False):
10481 "-f" ,
10582 str (dockerfile_path ),
10683 "--build-arg" ,
107- f"BASE_IMAGE={ platform_config ['base_image' ]} " ,
84+ f"BASE_IMAGE={ platform_config ['base_image' ]} @ { platform_config [ 'base_image_sha' ] } " ,
10885 "--label" ,
10986 f"dockerfile-hash={ current_hash } " ,
11087 "-t" ,
@@ -132,7 +109,8 @@ def build_image(platform_name, platform_config, script_dir, rebuild=False):
132109
133110def registry_image_ref (platform_name ):
134111 """Return the fully-qualified registry image reference for a platform."""
135- return f"{ IMAGE_REGISTRY } /{ PLATFORMS [platform_name ]['image_tag' ]} "
112+ platform = get_config ()[platform_name ]
113+ return f"{ IMAGE_REGISTRY } /{ platform ['image_name' ]} :{ platform ['image_version' ]} "
136114
137115
138116def pull_image (platform_name ):
@@ -152,24 +130,11 @@ def pull_image(platform_name):
152130 return ref
153131
154132
155- def image_exists_in_registry (platform_name ):
156- """Check if an image tag already exists in the registry."""
157- ref = registry_image_ref (platform_name )
158- result = subprocess .run (
159- ["docker" , "manifest" , "inspect" , ref ],
160- capture_output = True ,
161- text = True ,
162- )
163- return result .returncode == 0
164-
165-
166133def push_image (platform_name , local_tag ):
167- """Tag a local image with the registry reference and push it."""
168- ref = registry_image_ref (platform_name )
169-
170- if image_exists_in_registry (platform_name ):
171- log .error (f"Image { ref } already exists. Bump IMAGE_VERSION." )
172- sys .exit (1 )
134+ """Tag a local image with a timestamped version and push it."""
135+ image_name = get_config ()[platform_name ]["image_name" ]
136+ version = datetime .datetime .now (datetime .timezone .utc ).strftime ("%Y%m%dT%H%M%SZ" )
137+ ref = f"{ IMAGE_REGISTRY } /{ image_name } :{ version } "
173138
174139 log .info (f"Tagging { local_tag } as { ref } ..." )
175140 result = subprocess .run (["docker" , "tag" , local_tag , ref ])
@@ -183,6 +148,46 @@ def push_image(platform_name, local_tag):
183148 log .error ("Docker push failed." )
184149 sys .exit (1 )
185150
151+ log .info (f"Update image_version to \" { version } \" in platforms.json." )
152+
153+
154+ def latest_registry_version (image_name ):
155+ """Query ghcr.io for the latest tag of an image."""
156+ # Anonymous token — no credentials needed for public images
157+ token_url = f"https://ghcr.io/token?scope=repository:cfengine/{ image_name } :pull"
158+ token = json .loads (urllib .request .urlopen (token_url ).read ())["token" ]
159+
160+ tags_url = f"https://ghcr.io/v2/cfengine/{ image_name } /tags/list"
161+ req = urllib .request .Request (
162+ tags_url , headers = {"Authorization" : f"Bearer { token } " }
163+ )
164+ tags = json .loads (urllib .request .urlopen (req ).read ()).get ("tags" , [])
165+ if not tags :
166+ return None
167+ return sorted (tags )[- 1 ]
168+
169+
170+ def update_platform_versions (platform_name = None ):
171+ """Fetch latest image versions from the registry and update platforms.json."""
172+ config = get_config ()
173+
174+ platforms = [platform_name ] if platform_name else list (config .keys ())
175+ for name in platforms :
176+ image_name = config [name ]["image_name" ]
177+ latest = latest_registry_version (image_name )
178+ if latest is None :
179+ log .warning (f"No tags found for { image_name } , skipping." )
180+ continue
181+ old = config [name ]["image_version" ]
182+ if old == latest :
183+ log .info (f"{ name } : already at { latest } " )
184+ else :
185+ config [name ]["image_version" ] = latest
186+ log .info (f"{ name } : { old } -> { latest } " )
187+
188+ config_path = Path (__file__ ).resolve ().parent / "platforms.json"
189+ config_path .write_text (json .dumps (config , indent = 2 ) + "\n " )
190+
186191
187192def run_container (args , image_tag , source_dir , script_dir ):
188193 """Run the build inside a Docker container."""
@@ -252,7 +257,7 @@ def parse_args():
252257 )
253258 parser .add_argument (
254259 "--platform" ,
255- choices = list (PLATFORMS .keys ()),
260+ choices = list (get_config () .keys ()),
256261 help = "Target platform" ,
257262 )
258263 parser .add_argument (
@@ -300,6 +305,11 @@ def parse_args():
300305 action = "store_true" ,
301306 help = "Build image and push to registry, then exit" ,
302307 )
308+ parser .add_argument (
309+ "--update" ,
310+ action = "store_true" ,
311+ help = "Fetch latest image version from registry and update platforms.json" ,
312+ )
303313 parser .add_argument (
304314 "--shell" ,
305315 action = "store_true" ,
@@ -318,11 +328,15 @@ def parse_args():
318328
319329 if args .list_platforms :
320330 print ("Available platforms:" )
321- for name , config in PLATFORMS .items ():
331+ for name , config in get_config () .items ():
322332 print (f" { name :15s} ({ config ['base_image' ]} )" )
323333 sys .exit (0 )
324334
325- # --platform is always required (except --list-platforms handled above)
335+ if args .update :
336+ # --platform is optional for --update; updates all if omitted
337+ return args
338+
339+ # --platform is always required (except --list-platforms/--update handled above)
326340 if not args .platform :
327341 parser .error ("missing required argument --platform" )
328342
@@ -349,6 +363,10 @@ def main():
349363 format = "%(message)s" ,
350364 )
351365
366+ if args .update :
367+ update_platform_versions (args .platform )
368+ return
369+
352370 # Detect source directory
353371 if args .source_dir :
354372 source_dir = Path (args .source_dir ).resolve ()
@@ -357,7 +375,7 @@ def main():
357375
358376 script_dir = source_dir / "buildscripts"
359377
360- platform_config = PLATFORMS [args .platform ]
378+ platform_config = get_config () [args .platform ]
361379
362380 if args .push_image :
363381 image_tag = build_image (
0 commit comments