@@ -23,6 +23,12 @@ const (
2323 StatusFailed = "failed"
2424)
2525
26+ // StatusEvent represents a terminal status change for image readiness notifications.
27+ type StatusEvent struct {
28+ Status string
29+ Err error
30+ }
31+
2632type Manager interface {
2733 ListImages (ctx context.Context ) ([]Image , error )
2834 CreateImage (ctx context.Context , req CreateImageRequest ) (* Image , error )
@@ -38,14 +44,19 @@ type Manager interface {
3844 // TotalOCICacheBytes returns the total size of the OCI layer cache.
3945 // Used by the resource manager for disk capacity tracking.
4046 TotalOCICacheBytes (ctx context.Context ) (int64 , error )
47+ // WaitForReady blocks until the image identified by name reaches a terminal
48+ // state (ready or failed) or the context is cancelled.
49+ WaitForReady (ctx context.Context , name string ) error
4150}
4251
4352type manager struct {
44- paths * paths.Paths
45- ociClient * ociClient
46- queue * BuildQueue
47- createMu sync.Mutex
48- metrics * Metrics
53+ paths * paths.Paths
54+ ociClient * ociClient
55+ queue * BuildQueue
56+ createMu sync.Mutex
57+ metrics * Metrics
58+ readySubscribers map [string ][]chan StatusEvent // keyed by digestHex
59+ subscriberMu sync.RWMutex
4960}
5061
5162// NewManager creates a new image manager.
@@ -59,9 +70,10 @@ func NewManager(p *paths.Paths, maxConcurrentBuilds int, meter metric.Meter) (Ma
5970 }
6071
6172 m := & manager {
62- paths : p ,
63- ociClient : ociClient ,
64- queue : NewBuildQueue (maxConcurrentBuilds ),
73+ paths : p ,
74+ ociClient : ociClient ,
75+ queue : NewBuildQueue (maxConcurrentBuilds ),
76+ readySubscribers : make (map [string ][]chan StatusEvent ),
6577 }
6678
6779 // Initialize metrics if meter is provided
@@ -254,7 +266,7 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef) {
254266 m .updateStatusByDigest (ref , StatusConverting , nil )
255267
256268 diskPath := digestPath (m .paths , ref .Repository (), ref .DigestHex ())
257- // Use default image format (ext4 for now, easy to switch to erofs later )
269+ // Use default image format (erofs: read-only compressed with LZ4 )
258270 diskSize , err := ExportRootfs (tempDir , diskPath , DefaultImageFormat )
259271 if err != nil {
260272 m .updateStatusByDigest (ref , StatusFailed , fmt .Errorf ("convert to %s: %w" , DefaultImageFormat , err ))
@@ -286,6 +298,9 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef) {
286298 return
287299 }
288300
301+ // Notify subscribers that image is ready
302+ m .notifyReady (ref .DigestHex (), StatusReady , nil )
303+
289304 // Only create/update tag symlink on successful completion
290305 if ref .Tag () != "" {
291306 if err := createTagSymlink (m .paths , ref .Repository (), ref .Tag (), ref .DigestHex ()); err != nil {
@@ -317,6 +332,11 @@ func (m *manager) updateStatusByDigest(ref *ResolvedRef, status string, err erro
317332 }
318333
319334 writeMetadata (m .paths , ref .Repository (), ref .DigestHex (), meta )
335+
336+ // Notify subscribers of terminal status
337+ if status == StatusReady || status == StatusFailed {
338+ m .notifyReady (ref .DigestHex (), status , err )
339+ }
320340}
321341
322342func (m * manager ) RecoverInterruptedBuilds () {
@@ -476,3 +496,112 @@ func (m *manager) TotalOCICacheBytes(ctx context.Context) (int64, error) {
476496 }
477497 return total , nil
478498}
499+
500+ // WaitForReady blocks until the image reaches a terminal state (ready or failed)
501+ // or the context is cancelled.
502+ //
503+ // The image may not exist yet when this is called (e.g., the registry's
504+ // triggerConversion goroutine hasn't called ImportLocalImage yet), so we
505+ // poll briefly for the image to appear before subscribing for notifications.
506+ func (m * manager ) WaitForReady (ctx context.Context , name string ) error {
507+ // Wait for the image to appear in the store. In the build flow, the
508+ // registry triggers ImportLocalImage asynchronously after a push, so the
509+ // image may not exist when the build manager calls WaitForReady.
510+ const maxWaitForExist = 30 * time .Second
511+ const pollInterval = 100 * time .Millisecond
512+
513+ var img * Image
514+ deadline := time .Now ().Add (maxWaitForExist )
515+ for {
516+ got , err := m .GetImage (ctx , name )
517+ if err == nil {
518+ img = got
519+ break
520+ }
521+ if time .Now ().After (deadline ) {
522+ return fmt .Errorf ("get image: %w" , err )
523+ }
524+ select {
525+ case <- ctx .Done ():
526+ return ctx .Err ()
527+ case <- time .After (pollInterval ):
528+ }
529+ }
530+
531+ // Check if already in terminal state
532+ switch img .Status {
533+ case StatusReady :
534+ return nil
535+ case StatusFailed :
536+ return fmt .Errorf ("image conversion failed" )
537+ }
538+
539+ digestHex := strings .TrimPrefix (img .Digest , "sha256:" )
540+
541+ // Subscribe BEFORE re-checking to avoid TOCTOU race
542+ ch := make (chan StatusEvent , 1 )
543+ m .subscribeToReady (digestHex , ch )
544+ defer m .unsubscribeFromReady (digestHex , ch )
545+
546+ // Re-check after subscribing to close the race window
547+ img , err := m .GetImage (ctx , name )
548+ if err == nil {
549+ switch img .Status {
550+ case StatusReady :
551+ return nil
552+ case StatusFailed :
553+ return fmt .Errorf ("image conversion failed" )
554+ }
555+ }
556+
557+ // Wait for notification or context cancellation
558+ select {
559+ case event := <- ch :
560+ if event .Status == StatusReady {
561+ return nil
562+ }
563+ return fmt .Errorf ("image conversion failed" )
564+ case <- ctx .Done ():
565+ return ctx .Err ()
566+ }
567+ }
568+
569+ // subscribeToReady registers a channel for terminal status notifications on a digest.
570+ func (m * manager ) subscribeToReady (digestHex string , ch chan StatusEvent ) {
571+ m .subscriberMu .Lock ()
572+ defer m .subscriberMu .Unlock ()
573+ m .readySubscribers [digestHex ] = append (m .readySubscribers [digestHex ], ch )
574+ }
575+
576+ // unsubscribeFromReady removes a subscriber channel.
577+ func (m * manager ) unsubscribeFromReady (digestHex string , ch chan StatusEvent ) {
578+ m .subscriberMu .Lock ()
579+ defer m .subscriberMu .Unlock ()
580+
581+ subscribers := m .readySubscribers [digestHex ]
582+ for i , sub := range subscribers {
583+ if sub == ch {
584+ m .readySubscribers [digestHex ] = append (subscribers [:i ], subscribers [i + 1 :]... )
585+ break
586+ }
587+ }
588+
589+ if len (m .readySubscribers [digestHex ]) == 0 {
590+ delete (m .readySubscribers , digestHex )
591+ }
592+ }
593+
594+ // notifyReady broadcasts a terminal status event to all subscribers for a digest.
595+ func (m * manager ) notifyReady (digestHex string , status string , err error ) {
596+ m .subscriberMu .RLock ()
597+ defer m .subscriberMu .RUnlock ()
598+
599+ event := StatusEvent {Status : status , Err : err }
600+ for _ , ch := range m .readySubscribers [digestHex ] {
601+ // Non-blocking send — drop if channel is full
602+ select {
603+ case ch <- event :
604+ default :
605+ }
606+ }
607+ }
0 commit comments