Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 5 additions & 27 deletions lib/builds/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -948,42 +948,20 @@ func (m *manager) updateBuildComplete(id string, status string, digest *string,
m.notifyStatusChange(id, status)
}

// waitForImageReady polls the image manager until the build's image is ready.
// waitForImageReady blocks until the build's image reaches a terminal state.
// imageRef should be the short repo name (e.g., "builds/abc123" or "myapp")
// matching what triggerConversion stores in the image manager.
// This ensures that when a build reports "ready", the image is actually usable
// for instance creation (fixes KERNEL-863 race condition).
func (m *manager) waitForImageReady(ctx context.Context, imageRef string) error {
// Poll for up to 60 seconds (image conversion is typically fast)
const maxAttempts = 120
const pollInterval = 500 * time.Millisecond

m.logger.Debug("waiting for image to be ready", "image_ref", imageRef)

for attempt := 0; attempt < maxAttempts; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

img, err := m.imageManager.GetImage(ctx, imageRef)
if err == nil {
switch img.Status {
case images.StatusReady:
m.logger.Debug("image is ready", "image_ref", imageRef, "attempts", attempt+1)
return nil
case images.StatusFailed:
return fmt.Errorf("image conversion failed")
case images.StatusPending, images.StatusPulling, images.StatusConverting:
// Still processing, continue polling
}
}
// Image not found or still processing, wait and retry
time.Sleep(pollInterval)
if err := m.imageManager.WaitForReady(ctx, imageRef); err != nil {
return err
}

return fmt.Errorf("timeout waiting for image to be ready after %v", time.Duration(maxAttempts)*pollInterval)
m.logger.Debug("image is ready", "image_ref", imageRef)
return nil
}

// subscribeToStatus adds a subscriber channel for status updates on a build
Expand Down
44 changes: 43 additions & 1 deletion lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package builds
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -237,6 +239,7 @@ func (m *mockSecretProvider) GetSecrets(ctx context.Context, secretIDs []string)

// mockImageManager implements images.Manager for testing
type mockImageManager struct {
mu sync.RWMutex
images map[string]*images.Image
getImageErr error
}
Expand Down Expand Up @@ -274,11 +277,15 @@ func (m *mockImageManager) ImportLocalImage(ctx context.Context, repo, reference
}

func (m *mockImageManager) GetImage(ctx context.Context, name string) (*images.Image, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.getImageErr != nil {
return nil, m.getImageErr
}
if img, ok := m.images[name]; ok {
return img, nil
// Return a copy to avoid races on the Status field
imgCopy := *img
return &imgCopy, nil
}
return nil, images.ErrNotFound
}
Expand All @@ -298,14 +305,49 @@ func (m *mockImageManager) TotalOCICacheBytes(ctx context.Context) (int64, error
return 0, nil
}

func (m *mockImageManager) WaitForReady(ctx context.Context, name string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
m.mu.RLock()
img, ok := m.images[name]
var status string
if ok {
status = img.Status
}
m.mu.RUnlock()
switch status {
case images.StatusReady:
return nil
case images.StatusFailed:
return fmt.Errorf("image conversion failed")
}
time.Sleep(50 * time.Millisecond)
}
}

// SetImageReady sets an image to ready status for testing
func (m *mockImageManager) SetImageReady(name string) {
m.mu.Lock()
defer m.mu.Unlock()
m.images[name] = &images.Image{
Name: name,
Status: images.StatusReady,
}
}

// SetImageStatus sets an image's status in a thread-safe way for testing
func (m *mockImageManager) SetImageStatus(name, status string) {
m.mu.Lock()
defer m.mu.Unlock()
if img, ok := m.images[name]; ok {
img.Status = status
}
}

// Test helper to create a manager with test paths and mocks
func setupTestManager(t *testing.T) (*manager, *mockInstanceManager, *mockVolumeManager, string) {
mgr, instanceMgr, volumeMgr, _, tempDir := setupTestManagerWithImageMgr(t)
Expand Down
10 changes: 8 additions & 2 deletions lib/builds/race_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,19 @@ func TestWaitForImageReady_WaitsForConversion(t *testing.T) {
imageRef := "builds/" + buildID

// Start with image in pending status
imageMgr.mu.Lock()
imageMgr.images[imageRef] = &images.Image{
Name: imageRef,
Status: images.StatusPending,
}
imageMgr.mu.Unlock()

// Simulate conversion completing after a short delay
go func() {
time.Sleep(100 * time.Millisecond)
imageMgr.images[imageRef].Status = images.StatusConverting
imageMgr.SetImageStatus(imageRef, images.StatusConverting)
time.Sleep(100 * time.Millisecond)
imageMgr.images[imageRef].Status = images.StatusReady
imageMgr.SetImageStatus(imageRef, images.StatusReady)
}()

// waitForImageReady should poll and eventually succeed
Expand All @@ -131,10 +133,12 @@ func TestWaitForImageReady_ContextCancelled(t *testing.T) {
imageRef := "builds/" + buildID

// Image stays in pending status forever
imageMgr.mu.Lock()
imageMgr.images[imageRef] = &images.Image{
Name: imageRef,
Status: images.StatusPending,
}
imageMgr.mu.Unlock()

// waitForImageReady should return context error
err := mgr.waitForImageReady(ctx, imageRef)
Expand All @@ -152,10 +156,12 @@ func TestWaitForImageReady_Failed(t *testing.T) {
imageRef := "builds/" + buildID

// Image is in failed status
imageMgr.mu.Lock()
imageMgr.images[imageRef] = &images.Image{
Name: imageRef,
Status: images.StatusFailed,
}
imageMgr.mu.Unlock()

// waitForImageReady should return error immediately
err := mgr.waitForImageReady(ctx, imageRef)
Expand Down
16 changes: 12 additions & 4 deletions lib/images/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"

"github.com/u-root/u-root/pkg/cpio"
)
Expand All @@ -13,13 +14,20 @@ import (
type ExportFormat string

const (
FormatExt4 ExportFormat = "ext4" // Read-only ext4 (app images, default)
FormatErofs ExportFormat = "erofs" // Read-only compressed (future: when kernel supports it)
FormatExt4 ExportFormat = "ext4" // Read-only ext4 (legacy, used on Darwin)
FormatErofs ExportFormat = "erofs" // Read-only compressed with LZ4 (default on Linux)
FormatCpio ExportFormat = "cpio" // Uncompressed archive (initrd, fast boot)
)

// DefaultImageFormat is the default export format for OCI images
const DefaultImageFormat = FormatExt4
// DefaultImageFormat is the default export format for OCI images.
// On Linux, we use erofs (compressed, read-only) for smaller images.
// On Darwin, we use ext4 because the VZ kernel doesn't have erofs support.
var DefaultImageFormat = func() ExportFormat {
if runtime.GOOS == "darwin" {
return FormatExt4
}
return FormatErofs
}()

// ExportRootfs exports rootfs directory in specified format (public for system manager)
func ExportRootfs(rootfsDir, outputPath string, format ExportFormat) (int64, error) {
Expand Down
Loading