2727POCKET = "Release"
2828APP_NAME = "ppa-kolibri-server-copy-packages"
2929
30- TERMINAL_BUILD_STATES = frozenset (
31- {
32- "Successfully built" ,
33- "Failed to build" ,
34- "Chroot problem" ,
35- "Failed to upload" ,
36- "Cancelled build" ,
37- }
38- )
39-
40- FAILED_BUILD_STATES = frozenset (
41- {
42- "Failed to build" ,
43- "Chroot problem" ,
44- "Failed to upload" ,
45- "Cancelled build" ,
46- }
47- )
48-
4930log = logging .getLogger (APP_NAME )
5031
5132STARTUP_TIME = LAST_LOG_TIME = time .time ()
@@ -202,7 +183,7 @@ def get_builds_for(self, ppa, name, version, series_name):
202183
203184 def has_published_binaries (self , ppa , name , version , series_name ):
204185 builds = self .get_builds_for (ppa , name , version , series_name )
205- return not builds or builds [0 ].buildstate == "Successfully built"
186+ return bool ( builds ) and builds [0 ].buildstate == "Successfully built"
206187
207188 def get_usable_sources (self , ppa , package_names , series_name ):
208189 res = []
@@ -232,35 +213,43 @@ def get_usable_sources(self, ppa, package_names, series_name):
232213 res .append ((name , version ))
233214 return res
234215
235- def queue_copy (self , name , source_series , target_series , pocket ):
236- self .queue [source_series , target_series , pocket ].add (name )
216+ def queue_copy (self , name , version , source_series , target_series , pocket ):
217+ self .queue [source_series , target_series , pocket ].add (( name , version ) )
237218
238219 def perform_queued_copies (self , ppa ):
239220 first = True
240- for (source_series , target_series , pocket ), names in self .queue .items ():
241- if not names :
221+ failures = []
222+ for (source_series , target_series , pocket ), packages in self .queue .items ():
223+ if not packages :
242224 continue
243225 if first :
244226 log .info ("" )
245227 first = False
246- log .info ("Copying %s to %s" , ", " .join (sorted (names )), target_series )
228+ names = sorted (name for name , version in packages )
229+ log .info ("Copying %s to %s" , ", " .join (names ), target_series )
247230 try :
248231 ppa .syncSources (
249232 from_archive = ppa ,
250233 to_series = target_series ,
251234 to_pocket = pocket ,
252235 include_binaries = True ,
253- source_names = sorted ( names ) ,
236+ source_names = names ,
254237 )
255238 except lre .BadRequest as e :
256- if "same version already published" in str (e ):
239+ msg = str (e )
240+ if "same version already published" in msg :
257241 log .info ("Already copied to %s — skipping" , target_series )
258242 else :
259- raise
243+ log .error ("Failed to copy to %s: %s" , target_series , msg )
244+ failures .append (target_series )
245+ if failures :
246+ log .error ("Copy failed for series: %s" , ", " .join (failures ))
247+ return 1
248+ return 0
260249
261- def copy_to_series (self ):
250+ def copy_to_series (self , source_series = None ):
262251 """Copy packages from source series to all other supported Ubuntu series."""
263- source_series = get_current_series ()
252+ source_series = source_series or get_current_series ()
264253 log .info (
265254 "Spinning up the Launchpad API to copy targets in %s (source series: %s)" ,
266255 ", " .join (PACKAGE_WHITELIST ),
@@ -279,7 +268,7 @@ def copy_to_series(self):
279268 mentioned = True
280269 log .info ("%s %s missing from %s" , name , version , target_series_name )
281270 if self .has_published_binaries (ppa , name , version , source_series ):
282- self .queue_copy (name , source_series , target_series_name , POCKET )
271+ self .queue_copy (name , version , source_series , target_series_name , POCKET )
283272 else :
284273 builds = self .get_builds_for (ppa , name , version , source_series )
285274 if builds :
@@ -306,9 +295,9 @@ def copy_to_series(self):
306295 for notice in notices :
307296 log .info (notice )
308297
309- self .perform_queued_copies (ppa )
298+ result = self .perform_queued_copies (ppa )
310299 log .debug ("All done" )
311- return 0
300+ return result
312301
313302 def check_source (self , package , version , ppa_name = None ):
314303 """Check if a source package version exists in a PPA.
@@ -328,77 +317,78 @@ def check_source(self, package, version, ppa_name=None):
328317 log .info ("%s %s not found in %s" , package , version , ppa_name )
329318 return 1
330319
331- def wait_for_builds (self , package , version , ppa_name = None , timeout = 1800 , interval = 60 ):
332- """Wait for all builds of a source package to reach a terminal state .
320+ def wait_for_published (self , package , version , ppa_name = None , series = None , timeout = 1800 , interval = 60 ):
321+ """Wait for published binaries to appear for a package .
333322
334- Returns 0 if all builds succeed, 1 on failure or timeout.
323+ If series is given, waits for those specific series to have published binaries.
324+ If series is None, discovers all series that have a published source for this
325+ package+version and waits until every one of them also has published binaries.
326+ Returns 0 if all expected series are published, 1 on failure or timeout.
335327 """
336328 ppa_name = ppa_name or PROPOSED_PPA_NAME
337329 ppa = self .get_ppa (ppa_name )
338330 deadline = time .time () + timeout
331+ expected = set (series ) if series else None
332+
333+ log .info (
334+ "Waiting for %s %s to be published in %s%s..." ,
335+ package ,
336+ version ,
337+ ppa_name ,
338+ " for series: %s" % ", " .join (sorted (expected )) if expected else "" ,
339+ )
339340
340- # Phase 1: Wait for source to appear
341- log .info ("Waiting for %s %s to appear in %s..." , package , version , ppa_name )
342- sources = []
343341 while time .time () < deadline :
344- published = ppa .getPublishedSources (
345- source_name = package ,
342+ # If no explicit series, discover from published sources
343+ if expected is None :
344+ sources = ppa .getPublishedSources (
345+ source_name = package ,
346+ version = version ,
347+ order_by_date = True ,
348+ )
349+ source_series = set ()
350+ for s in sources :
351+ if s .status not in ("Deleted" , "Superseded" , "Obsolete" ):
352+ series_name = s .distro_series_link .rstrip ("/" ).split ("/" )[- 1 ]
353+ source_series .add (series_name )
354+ if not source_series :
355+ log .info ("No published sources yet." )
356+ remaining = int (deadline - time .time ())
357+ log .info ("Retrying in %ds (%ds remaining)..." , interval , remaining )
358+ time .sleep (interval )
359+ continue
360+ expected = source_series
361+ log .info ("Discovered %d series with sources: %s" , len (expected ), ", " .join (sorted (expected )))
362+
363+ # Check published binaries
364+ bins = ppa .getPublishedBinaries (
365+ binary_name = package ,
346366 version = version ,
347367 order_by_date = True ,
348368 )
349- sources = [s for s in published if s .status not in ("Deleted" , "Superseded" , "Obsolete" )]
350- if sources :
351- log .info ("Found %d source(s) for %s %s" , len (sources ), package , version )
352- break
353- remaining = int (deadline - time .time ())
354- log .info ("Source not yet available. Retrying in %ds (%ds remaining)..." , interval , remaining )
355- time .sleep (interval )
356- else :
357- log .error ("Timeout: %s %s did not appear in %s within %ds" , package , version , ppa_name , timeout )
358- return 1
359-
360- # Phase 2: Wait for all builds to complete
361- return self ._poll_builds (sources , package , version , deadline , interval )
362-
363- def _poll_builds (self , sources , package , version , deadline , interval ):
364- """Poll builds for all sources until terminal state or timeout."""
365- log .info ("Waiting for builds to complete..." )
366- while time .time () < deadline :
367- all_terminal = True
368- total = 0
369- succeeded = 0
370- failed = []
371- building = 0
372-
373- for source in sources :
374- builds = source .getBuilds ()
375- for build in builds :
376- total += 1
377- state = build .buildstate
378- if state == "Successfully built" :
379- succeeded += 1
380- elif state in FAILED_BUILD_STATES :
381- failed .append ((build .arch_tag , state , build .web_link ))
382- else :
383- building += 1
384- all_terminal = False
385-
386- if failed :
387- log .error ("Build failures detected:" )
388- for arch , state , link in failed :
389- log .error (" %s: %s - %s" , arch , state , link )
390- return 1
391-
392- if total > 0 and all_terminal :
393- log .info ("All %d build(s) completed successfully." , total )
369+ published_series = set ()
370+ for b in bins :
371+ if b .status == "Published" :
372+ # distro_arch_series_link: .../ubuntu/noble/amd64
373+ series_name = b .distro_arch_series_link .rstrip ("/" ).split ("/" )[- 2 ]
374+ published_series .add (series_name )
375+
376+ missing = expected - published_series
377+ if not missing :
378+ log .info ("All %d series published: %s" , len (expected ), ", " .join (sorted (published_series )))
394379 return 0
380+ log .info (
381+ "Published in %d/%d series. Missing: %s" ,
382+ len (expected ) - len (missing ),
383+ len (expected ),
384+ ", " .join (sorted (missing )),
385+ )
395386
396- log .info ("Waiting for builds: %d/%d complete, %d building..." , succeeded , total , building )
397387 remaining = int (deadline - time .time ())
398388 log .info ("Retrying in %ds (%ds remaining)..." , interval , remaining )
399389 time .sleep (interval )
400390
401- log .error ("Timeout: builds for %s %s did not complete within timeout " , package , version )
391+ log .error ("Timeout: %s %s not published within %ds " , package , version , timeout )
402392 return 1
403393
404394 def promote (self ):
@@ -471,19 +461,22 @@ def build_parser():
471461 parser .add_argument ("--debug" , action = "store_true" , help = "Enable HTTP debug output." )
472462 subparsers = parser .add_subparsers (dest = "command" , required = True )
473463
474- subparsers .add_parser (
464+ copy_parser = subparsers .add_parser (
475465 "copy-to-series" ,
476466 help = "Copy packages from source series to all other supported series within a PPA." ,
477467 )
468+ copy_parser .add_argument (
469+ "--series" , default = None , help = "Source series override (default: auto-detect from OS)."
470+ )
478471
479472 subparsers .add_parser (
480473 "promote" ,
481474 help = "Promote published packages from kolibri-proposed to kolibri PPA." ,
482475 )
483476
484477 wait_parser = subparsers .add_parser (
485- "wait-for-builds " ,
486- help = "Wait for Launchpad builds to complete for a source package." ,
478+ "wait-for-published " ,
479+ help = "Wait for published binaries to appear for a source package." ,
487480 )
488481 wait_parser .add_argument ("--package" , required = True , help = "Source package name." )
489482 wait_parser .add_argument ("--version" , required = True , help = "Expected version string." )
@@ -492,6 +485,9 @@ def build_parser():
492485 wait_parser .add_argument (
493486 "--interval" , type = int , default = 60 , help = "Polling interval in seconds (default: %(default)s)."
494487 )
488+ wait_parser .add_argument (
489+ "--series" , nargs = "+" , default = None , help = "Series to wait for (default: any)."
490+ )
495491
496492 check_parser = subparsers .add_parser (
497493 "check-source" ,
@@ -521,16 +517,17 @@ def configure_logging(args):
521517def cmd_copy_to_series (args ):
522518 """Copy packages from source series to all other supported Ubuntu series."""
523519 lp = LaunchpadWrapper ()
524- return lp .copy_to_series ()
520+ return lp .copy_to_series (source_series = args . series )
525521
526522
527- def cmd_wait_for_builds (args ):
528- """Wait for Launchpad builds to complete ."""
523+ def cmd_wait_for_published (args ):
524+ """Wait for published binaries to appear ."""
529525 lp = LaunchpadWrapper ()
530- return lp .wait_for_builds (
526+ return lp .wait_for_published (
531527 package = args .package ,
532528 version = args .version ,
533529 ppa_name = args .ppa ,
530+ series = args .series ,
534531 timeout = args .timeout ,
535532 interval = args .interval ,
536533 )
@@ -563,8 +560,8 @@ def main():
563560 return cmd_check_source (args )
564561 elif args .command == "promote" :
565562 return cmd_promote (args )
566- elif args .command == "wait-for-builds " :
567- return cmd_wait_for_builds (args )
563+ elif args .command == "wait-for-published " :
564+ return cmd_wait_for_published (args )
568565
569566
570567if __name__ == "__main__" :
0 commit comments