diff --git a/cmd/static-server/main.go b/cmd/static-server/main.go index feb8254f..f564ad5c 100644 --- a/cmd/static-server/main.go +++ b/cmd/static-server/main.go @@ -106,7 +106,7 @@ func loadStaticNMState(fsys fs.FS, env *env.EnvInputs, nmstateDir string, imageS imageName := strings.TrimSuffix(f.Name(), ".yaml") + suffix isInitramfs := !strings.HasSuffix(imageName, ".iso") - url, err := imageServer.ServeImage(imageName, "", ign, isInitramfs, true) + url, err := imageServer.ServeImage(imageName, "", "", ign, isInitramfs, true) if err != nil { return err } diff --git a/cmd/static-server/main_test.go b/cmd/static-server/main_test.go index 7d8e4b00..1096c3fc 100644 --- a/cmd/static-server/main_test.go +++ b/cmd/static-server/main_test.go @@ -42,13 +42,13 @@ func (f *fakeImageFileSystem) Seek(offset int64, whence int) (int64, error) { re func (f *fakeImageFileSystem) Readdir(n int) ([]fs.FileInfo, error) { return nil, nil } func (f *fakeImageFileSystem) Open(name string) (http.File, error) { return nil, nil } func (f *fakeImageFileSystem) FileSystem() http.FileSystem { return f } -func (f *fakeImageFileSystem) ServeImage(name string, arch string, ignitionContent []byte, initrd, static bool) (string, error) { +func (f *fakeImageFileSystem) ServeImage(name string, arch string, stream string, ignitionContent []byte, initrd, static bool) (string, error) { f.imagesServed = append(f.imagesServed, name) return "", nil } -func (f *fakeImageFileSystem) ServeKernel(arch string) (string, error) { return "", nil } -func (f *fakeImageFileSystem) RemoveImage(name string) {} -func (f *fakeImageFileSystem) HasImagesForArchitecture(arch string) bool { return true } +func (f *fakeImageFileSystem) ServeKernel(arch string, stream string) (string, error) { return "", nil } +func (f *fakeImageFileSystem) RemoveImage(name string) {} +func (f *fakeImageFileSystem) HasImagesForArchitecture(arch string) bool { return true } func TestLoadStaticNMState(t *testing.T) { fifs := &fakeImageFileSystem{imagesServed: []string{}} diff --git a/pkg/imagehandler/imagefile.go b/pkg/imagehandler/imagefile.go index 75bc192f..3a0b87ae 100644 --- a/pkg/imagehandler/imagefile.go +++ b/pkg/imagehandler/imagefile.go @@ -27,6 +27,7 @@ type imageFile struct { name string size int64 arch string + stream string ignitionContent []byte imageReader isoeditor.ImageReader initramfs bool diff --git a/pkg/imagehandler/imagefilesystem.go b/pkg/imagehandler/imagefilesystem.go index 950e0364..bbdd7d86 100644 --- a/pkg/imagehandler/imagefilesystem.go +++ b/pkg/imagehandler/imagefilesystem.go @@ -59,9 +59,9 @@ func (f *imageFileSystem) Open(name string) (http.File, error) { var baseImage baseFile if im.kernel { - baseImage = f.getKernel(im.arch) + baseImage = f.getKernel(im.arch, im.stream) } else { - baseImage = f.getBaseImage(im.arch, im.initramfs) + baseImage = f.getBaseImage(im.arch, im.stream, im.initramfs) } if err := im.Init(baseImage); err != nil { diff --git a/pkg/imagehandler/imagehandler.go b/pkg/imagehandler/imagehandler.go index a6168159..16eb9b40 100644 --- a/pkg/imagehandler/imagehandler.go +++ b/pkg/imagehandler/imagehandler.go @@ -34,6 +34,12 @@ const ( hostArchitectureKey = "host" ) +// streamArchKey identifies a base image by its OS stream and CPU architecture. +type streamArchKey struct { + stream string // e.g. "rhel-9", "rhel-10"; empty = default/legacy + arch string // e.g. "x86_64", "aarch64", "host" +} + // matchArchFilename attempts to match a target filename against a base filename. // Returns "host" for exact matches, the architecture for pattern matches, or nil if no match. func matchArchFilename(baseFilename, targetFilename string) *string { @@ -63,6 +69,50 @@ func matchArchFilename(baseFilename, targetFilename string) *string { return nil } +// matchStreamFilename attempts to extract a stream name (and optionally an +// architecture) from a target filename, given a base filename pattern. +// +// It matches filenames of the form: +// +// baseName-STREAM.ext → stream=STREAM, arch="host" +// baseName-STREAM[_.]ARCH.ext → stream=STREAM, arch=ARCH +// +// Returns nil if the filename does not match the stream pattern. +func matchStreamFilename(baseFilename, targetFilename string) *osImage { + if baseFilename == "" { + return nil + } + + base := filepath.Base(baseFilename) + ext := filepath.Ext(base) + baseName := strings.TrimSuffix(base, ext) + + target := filepath.Base(targetFilename) + + // Pattern: baseName-STREAM([_.]ARCH)?ext + // STREAM is captured as a non-greedy match of any characters after a dash. + // ARCH is captured as a word after an underscore or period separator. + patternStr := fmt.Sprintf(`^%s-(.+?)(?:[_.](\w+))?%s$`, regexp.QuoteMeta(baseName), regexp.QuoteMeta(ext)) + pattern := regexp.MustCompile(patternStr) + + matches := pattern.FindStringSubmatch(target) + if matches == nil { + return nil + } + + stream := matches[1] + arch := matches[2] + if arch == "" { + arch = hostArchitectureKey + } + + return &osImage{ + filename: targetFilename, + stream: stream, + arch: arch, + } +} + type imageKind int const ( @@ -74,32 +124,41 @@ const ( type osImage struct { filename string arch string + stream string kind imageKind } func loadOSImage(envInputs *env.EnvInputs, filename string) (osImage, error) { - if arch := matchArchFilename(envInputs.DeployISO, filename); arch != nil { - return osImage{ - filename: filename, - arch: *arch, - kind: imageKindISO, - }, nil + type matcher struct { + base string + kind imageKind } - if arch := matchArchFilename(envInputs.DeployInitrd, filename); arch != nil { - return osImage{ - filename: filename, - arch: *arch, - kind: imageKindInitramfs, - }, nil + matchers := []matcher{ + {envInputs.DeployISO, imageKindISO}, + {envInputs.DeployInitrd, imageKindInitramfs}, + } + if envInputs.DeployKernel != "" { + matchers = append(matchers, matcher{envInputs.DeployKernel, imageKindKernel}) } - if arch := matchArchFilename(envInputs.DeployKernel, filename); arch != nil { - return osImage{ - filename: filename, - arch: *arch, - kind: imageKindKernel, - }, nil + // Try stream+arch matching first (e.g. ipa-rhel-9_aarch64.iso) + for _, m := range matchers { + if img := matchStreamFilename(m.base, filename); img != nil { + img.kind = m.kind + return *img, nil + } + } + + // Fall back to arch-only matching (e.g. ipa_aarch64.iso, ipa.iso) + for _, m := range matchers { + if arch := matchArchFilename(m.base, filename); arch != nil { + return osImage{ + filename: filename, + arch: *arch, + kind: m.kind, + }, nil + } } return osImage{}, fmt.Errorf("failed to load os image name: %s", filename) @@ -120,9 +179,9 @@ func (ie InvalidBaseImageError) Unwrap() error { // imageFileSystem is an http.FileSystem that creates a virtual filesystem of // host images. type imageFileSystem struct { - isoFiles map[string]*baseIso - initramfsFiles map[string]*baseInitramfs - kernelFiles map[string]*baseKernel + isoFiles map[streamArchKey]*baseIso + initramfsFiles map[streamArchKey]*baseInitramfs + kernelFiles map[streamArchKey]*baseKernel baseURL *url.URL keys map[string]string images map[string]*imageFile @@ -135,8 +194,8 @@ var _ http.FileSystem = &imageFileSystem{} type ImageHandler interface { FileSystem() http.FileSystem - ServeImage(key string, arch string, ignitionContent []byte, initramfs, static bool) (string, error) - ServeKernel(arch string) (string, error) + ServeImage(key string, arch string, stream string, ignitionContent []byte, initramfs, static bool) (string, error) + ServeKernel(arch string, stream string) (string, error) RemoveImage(key string) HasImagesForArchitecture(arch string) bool } @@ -185,9 +244,9 @@ func findOSImageCandidates(logger logr.Logger, envInputs *env.EnvInputs, filePat func NewImageHandler(logger logr.Logger, baseURL *url.URL, envInputs *env.EnvInputs) (ImageHandler, error) { filePaths := findOSImageCandidates(logger, envInputs, nil) - isoFiles := map[string]*baseIso{} - initramfsFiles := map[string]*baseInitramfs{} - kernelFiles := map[string]*baseKernel{} + isoFiles := map[streamArchKey]*baseIso{} + initramfsFiles := map[streamArchKey]*baseInitramfs{} + kernelFiles := map[streamArchKey]*baseKernel{} logger.Info("processing image files", "total", len(filePaths)) for _, filePath := range filePaths { @@ -199,15 +258,16 @@ func NewImageHandler(logger logr.Logger, baseURL *url.URL, envInputs *env.EnvInp continue } - logger.Info("image loaded", "filename", osImage.filename, "arch", osImage.arch, "kind", osImage.kind) + key := streamArchKey{stream: osImage.stream, arch: osImage.arch} + logger.Info("image loaded", "filename", osImage.filename, "arch", osImage.arch, "stream", osImage.stream, "kind", osImage.kind) switch osImage.kind { case imageKindISO: - isoFiles[osImage.arch] = newBaseIso(filePath) + isoFiles[key] = newBaseIso(filePath) case imageKindInitramfs: - initramfsFiles[osImage.arch] = newBaseInitramfs(filePath) + initramfsFiles[key] = newBaseInitramfs(filePath) case imageKindKernel: - kernelFiles[osImage.arch] = newBaseKernel(filePath) + kernelFiles[key] = newBaseKernel(filePath) } } @@ -227,68 +287,93 @@ func (f *imageFileSystem) FileSystem() http.FileSystem { return f } -func (f *imageFileSystem) getBaseImage(arch string, initramfs bool) baseFile { +func (f *imageFileSystem) getBaseImage(arch string, stream string, initramfs bool) baseFile { if arch == "" { arch = hostArchitectureKey } - f.log.Info("getBaseImage", "arch", arch, "initramfs", initramfs) + f.log.Info("getBaseImage", "arch", arch, "stream", stream, "initramfs", initramfs) - getFile := func(arch string) baseFile { + getFile := func(key streamArchKey) baseFile { if initramfs { - if file, exists := f.initramfsFiles[arch]; exists { + if file, exists := f.initramfsFiles[key]; exists { return file } } else { - if file, exists := f.isoFiles[arch]; exists { + if file, exists := f.isoFiles[key]; exists { return file } } return nil } - if file := getFile(arch); file != nil { + // Try exact (stream, arch) match + if file := getFile(streamArchKey{stream: stream, arch: arch}); file != nil { return file } + // Fall back to default stream (empty) for the same arch + if stream != "" { + if file := getFile(streamArchKey{stream: "", arch: arch}); file != nil { + return file + } + } + + // Fall back to host architecture key if arch == env.HostArchitecture() { - if file := getFile(hostArchitectureKey); file != nil { + if file := getFile(streamArchKey{stream: stream, arch: hostArchitectureKey}); file != nil { return file } + if stream != "" { + if file := getFile(streamArchKey{stream: "", arch: hostArchitectureKey}); file != nil { + return file + } + } } return nil } -func (f *imageFileSystem) getKernel(arch string) baseFile { +func (f *imageFileSystem) getKernel(arch string, stream string) baseFile { if arch == "" { arch = hostArchitectureKey } - f.log.Info("getKernel", "arch", arch) + f.log.Info("getKernel", "arch", arch, "stream", stream) - getFile := func(arch string) baseFile { - if file, exists := f.kernelFiles[arch]; exists { + getFile := func(key streamArchKey) baseFile { + if file, exists := f.kernelFiles[key]; exists { return file } return nil } - if file := getFile(arch); file != nil { + if file := getFile(streamArchKey{stream: stream, arch: arch}); file != nil { return file } + if stream != "" { + if file := getFile(streamArchKey{stream: "", arch: arch}); file != nil { + return file + } + } + if arch == env.HostArchitecture() { - if file := getFile(hostArchitectureKey); file != nil { + if file := getFile(streamArchKey{stream: stream, arch: hostArchitectureKey}); file != nil { return file } + if stream != "" { + if file := getFile(streamArchKey{stream: "", arch: hostArchitectureKey}); file != nil { + return file + } + } } return nil } -func (f *imageFileSystem) ServeKernel(arch string) (string, error) { - kernel := f.getKernel(arch) +func (f *imageFileSystem) ServeKernel(arch string, stream string) (string, error) { + kernel := f.getKernel(arch, stream) if kernel == nil { return "", nil } @@ -298,7 +383,7 @@ func (f *imageFileSystem) ServeKernel(arch string) (string, error) { return "", err } - key := fmt.Sprintf("kernel-%s", arch) + key := fmt.Sprintf("kernel-%s-%s", stream, arch) f.mu.Lock() defer f.mu.Unlock() @@ -321,6 +406,7 @@ func (f *imageFileSystem) ServeKernel(arch string) (string, error) { f.images[key] = &imageFile{ name: name, arch: arch, + stream: stream, size: size, kernel: true, } @@ -329,7 +415,28 @@ func (f *imageFileSystem) ServeKernel(arch string) (string, error) { } func (f *imageFileSystem) HasImagesForArchitecture(arch string) bool { - return f.getBaseImage(arch, false) != nil && f.getBaseImage(arch, true) != nil + streams := f.availableStreams() + for _, stream := range streams { + if f.getBaseImage(arch, stream, false) != nil && f.getBaseImage(arch, stream, true) != nil { + return true + } + } + return false +} + +func (f *imageFileSystem) availableStreams() []string { + seen := map[string]struct{}{} + for key := range f.isoFiles { + seen[key.stream] = struct{}{} + } + for key := range f.initramfsFiles { + seen[key.stream] = struct{}{} + } + streams := make([]string, 0, len(seen)) + for s := range seen { + streams = append(streams, s) + } + return streams } func (f *imageFileSystem) getNameForKey(key string) (name string, err error) { @@ -343,9 +450,12 @@ func (f *imageFileSystem) getNameForKey(key string) (name string, err error) { return } -func (f *imageFileSystem) ServeImage(key string, arch string, ignitionContent []byte, initramfs, static bool) (string, error) { - f.log.Info("ServeImage") - baseImage := f.getBaseImage(arch, initramfs) +func (f *imageFileSystem) ServeImage(key string, arch string, stream string, ignitionContent []byte, initramfs, static bool) (string, error) { + f.log.Info("ServeImage", "arch", arch, "stream", stream) + baseImage := f.getBaseImage(arch, stream, initramfs) + if baseImage == nil { + return "", InvalidBaseImageError{cause: fmt.Errorf("no base image found for arch=%s stream=%s initramfs=%v", arch, stream, initramfs)} + } size, err := baseImage.Size() if err != nil { @@ -372,6 +482,7 @@ func (f *imageFileSystem) ServeImage(key string, arch string, ignitionContent [] f.images[key] = &imageFile{ name: name, arch: arch, + stream: stream, size: size, ignitionContent: ignitionContent, initramfs: initramfs, diff --git a/pkg/imagehandler/imagehandler_test.go b/pkg/imagehandler/imagehandler_test.go index f5c1840f..1cb6c95c 100644 --- a/pkg/imagehandler/imagehandler_test.go +++ b/pkg/imagehandler/imagehandler_test.go @@ -53,8 +53,8 @@ func TestImageHandler(t *testing.T) { rr := httptest.NewRecorder() imageServer := &imageFileSystem{ log: zap.New(zap.UseDevMode(true)), - isoFiles: map[string]*baseIso{ - "host": {baseFileData{filename: "dummyfile.iso", size: 12345}}, + isoFiles: map[streamArchKey]*baseIso{ + {arch: "host"}: {baseFileData{filename: "dummyfile.iso", size: 12345}}, }, baseURL: baseURL, keys: map[string]string{ @@ -99,23 +99,23 @@ func TestNewImageHandler(t *testing.T) { keys: map[string]string{}, mu: &sync.Mutex{}, images: map[string]*imageFile{}, - isoFiles: map[string]*baseIso{}, - initramfsFiles: map[string]*baseInitramfs{}, + isoFiles: map[streamArchKey]*baseIso{}, + initramfsFiles: map[streamArchKey]*baseInitramfs{}, } iso := newBaseIso("dummyfile.iso") iso.size = 123456 - ifs.isoFiles["host"] = iso + ifs.isoFiles[streamArchKey{arch: "host"}] = iso initramfs := newBaseInitramfs("dummyfile.initramfs") initramfs.size = 12345 - ifs.initramfsFiles["host"] = initramfs + ifs.initramfsFiles[streamArchKey{arch: "host"}] = initramfs - url1, err := ifs.ServeImage("test-key-1", "", []byte{}, false, false) + url1, err := ifs.ServeImage("test-key-1", "", "", []byte{}, false, false) if err != nil { t.Fatalf("unexpected error %v", err) } - url2, err := ifs.ServeImage("test-key-2", "", []byte{}, true, false) + url2, err := ifs.ServeImage("test-key-2", "", "", []byte{}, true, false) if err != nil { t.Fatalf("unexpected error %v", err) } @@ -125,7 +125,7 @@ func TestNewImageHandler(t *testing.T) { t.Errorf("can't look up image file \"%s\"", name2) } - url1again, err := ifs.ServeImage("test-key-1", "", []byte{}, false, false) + url1again, err := ifs.ServeImage("test-key-1", "", "", []byte{}, false, false) if err != nil { t.Fatalf("unexpected error %v", err) } @@ -135,7 +135,7 @@ func TestNewImageHandler(t *testing.T) { } ifs.RemoveImage("test-key-1") - url1yetagain, err := ifs.ServeImage("test-key-1", "", []byte{}, false, false) + url1yetagain, err := ifs.ServeImage("test-key-1", "", "", []byte{}, false, false) if err != nil { t.Fatalf("unexpected error %v", err) } @@ -155,27 +155,27 @@ func TestNewImageHandlerStatic(t *testing.T) { keys: map[string]string{}, mu: &sync.Mutex{}, images: map[string]*imageFile{}, - isoFiles: map[string]*baseIso{}, - initramfsFiles: map[string]*baseInitramfs{}, + isoFiles: map[streamArchKey]*baseIso{}, + initramfsFiles: map[streamArchKey]*baseInitramfs{}, } iso := newBaseIso("dummyfile.iso") iso.size = 123456 - ifs.isoFiles["host"] = iso + ifs.isoFiles[streamArchKey{arch: "host"}] = iso initramfs := newBaseInitramfs("dummyfile.initramfs") initramfs.size = 12345 - ifs.initramfsFiles["host"] = initramfs + ifs.initramfsFiles[streamArchKey{arch: "host"}] = initramfs - url1, err := ifs.ServeImage("test-name-1.iso", "", []byte{}, false, true) + url1, err := ifs.ServeImage("test-name-1.iso", "", "", []byte{}, false, true) if err != nil { t.Fatalf("unexpected error %v", err) } - url2, err := ifs.ServeImage("test-name-2.initramfs", "", []byte{}, true, true) + url2, err := ifs.ServeImage("test-name-2.initramfs", "", "", []byte{}, true, true) if err != nil { t.Fatalf("unexpected error %v", err) } - url1again, err := ifs.ServeImage("test-name-1.iso", "", []byte{}, false, true) + url1again, err := ifs.ServeImage("test-name-1.iso", "", "", []byte{}, false, true) if err != nil { t.Fatalf("unexpected error %v", err) } @@ -306,6 +306,107 @@ func TestImagePattern(t *testing.T) { } } +func TestImagePatternWithStream(t *testing.T) { + envInputs := &env.EnvInputs{ + DeployISO: "/config/ipa.iso", + DeployInitrd: "/config/ipa.initramfs", + } + + tcs := []struct { + name string + filename string + arch string + stream string + kind imageKind + error bool + }{ + { + name: "stream iso - host arch", + filename: "/config/ipa-rhel-9.iso", + arch: "host", + stream: "rhel-9", + kind: imageKindISO, + }, + { + name: "stream iso with arch", + filename: "/config/ipa-rhel-9_aarch64.iso", + arch: "aarch64", + stream: "rhel-9", + kind: imageKindISO, + }, + { + name: "stream initramfs - host arch", + filename: "/config/ipa-rhel-10.initramfs", + arch: "host", + stream: "rhel-10", + kind: imageKindInitramfs, + }, + { + name: "stream initramfs with arch (period separator)", + filename: "/config/ipa-rhel-10.x86_64.initramfs", + arch: "x86_64", + stream: "rhel-10", + kind: imageKindInitramfs, + }, + { + name: "stream iso with arch (underscore separator)", + filename: "/images/ipa-rhel-10_aarch64.iso", + arch: "aarch64", + stream: "rhel-10", + kind: imageKindISO, + }, + { + name: "non-stream file still matches as arch-only", + filename: "/config/ipa_aarch64.iso", + arch: "aarch64", + kind: imageKindISO, + }, + { + name: "exact match still works", + filename: "/config/ipa.iso", + arch: "host", + kind: imageKindISO, + }, + { + name: "invalid filename - different base name with stream", + filename: "/images/different-rhel-9_x86_64.iso", + error: true, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ii, err := loadOSImage(envInputs, tc.filename) + + if err != nil && !tc.error { + t.Errorf("got error: %v", err) + return + } + + if err == nil && tc.error { + t.Errorf("expected error but got none") + return + } + + if tc.error { + return + } + + if ii.arch != tc.arch { + t.Errorf("arch: expected %s but got %s", tc.arch, ii.arch) + } + + if ii.stream != tc.stream { + t.Errorf("stream: expected %s but got %s", tc.stream, ii.stream) + } + + if ii.kind != tc.kind { + t.Errorf("kind: expected %d but got %d", tc.kind, ii.kind) + } + }) + } +} + func TestImagePatternWithKernel(t *testing.T) { envInputs := &env.EnvInputs{ DeployISO: "/config/ipa.iso", @@ -570,7 +671,7 @@ func TestArchitectureFallback(t *testing.T) { } // Test ISO fallback - should succeed because it falls back to host image - isoURL, err := handler.ServeImage("test-key", hostArch, []byte{}, false, false) + isoURL, err := handler.ServeImage("test-key", hostArch, "", []byte{}, false, false) if err != nil { t.Errorf("Expected ISO fallback to succeed for arch %s, got error: %v", hostArch, err) } @@ -579,7 +680,7 @@ func TestArchitectureFallback(t *testing.T) { } // Test initramfs fallback - should succeed because it falls back to host image - initramfsURL, err := handler.ServeImage("test-key-initramfs", hostArch, []byte{}, true, false) + initramfsURL, err := handler.ServeImage("test-key-initramfs", hostArch, "", []byte{}, true, false) if err != nil { t.Errorf("Expected initramfs fallback to succeed for arch %s, got error: %v", hostArch, err) } @@ -720,6 +821,55 @@ func TestHasImagesForArchitectureWithKernel(t *testing.T) { } } +func TestHasImagesForArchitectureWithStreams(t *testing.T) { + tempDir := t.TempDir() + + envInputs := &env.EnvInputs{ + DeployISO: filepath.Join(tempDir, "ipa.iso"), + DeployInitrd: filepath.Join(tempDir, "ipa.initramfs"), + ImageSharedDir: tempDir, + } + + // Create stream-qualified images only (no default-stream images) + err := os.WriteFile(filepath.Join(tempDir, "ipa-rhel-9_aarch64.iso"), []byte("rhel9 aarch64 iso"), 0600) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(tempDir, "ipa-rhel-9_aarch64.initramfs"), []byte("rhel9 aarch64 initramfs"), 0600) + if err != nil { + t.Fatal(err) + } + + baseUrl, err := url.Parse("http://base.test:1234") + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + logger := zap.New(zap.UseDevMode(true)) + handler, err := NewImageHandler(logger, baseUrl, envInputs) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + arch string + supported bool + desc string + }{ + {"aarch64", true, "aarch64 with stream-qualified ISO and initramfs"}, + {"x86_64", false, "x86_64 with no files in any stream"}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + supported := handler.HasImagesForArchitecture(tc.arch) + if supported != tc.supported { + t.Errorf("HasImagesForArchitecture(%s): expected %t, got %t", tc.arch, tc.supported, supported) + } + }) + } +} + func TestServeKernel(t *testing.T) { tempDir := t.TempDir() @@ -745,11 +895,11 @@ func TestServeKernel(t *testing.T) { ifs := &imageFileSystem{ log: logger, - isoFiles: map[string]*baseIso{}, - initramfsFiles: map[string]*baseInitramfs{}, - kernelFiles: map[string]*baseKernel{ - "host": newBaseKernel(kernelPath), - "aarch64": newBaseKernel(aarch64KernelPath), + isoFiles: map[streamArchKey]*baseIso{}, + initramfsFiles: map[streamArchKey]*baseInitramfs{}, + kernelFiles: map[streamArchKey]*baseKernel{ + {arch: "host"}: newBaseKernel(kernelPath), + {arch: "aarch64"}: newBaseKernel(aarch64KernelPath), }, baseURL: baseUrl, keys: map[string]string{}, @@ -758,17 +908,17 @@ func TestServeKernel(t *testing.T) { } // Test serving kernel for aarch64 - kernelURL, err := ifs.ServeKernel("aarch64") + kernelURL, err := ifs.ServeKernel("aarch64", "") if err != nil { t.Fatalf("unexpected error serving aarch64 kernel: %v", err) } - expected := "http://base.test:1234/kernel-aarch64" + expected := "http://base.test:1234/kernel--aarch64" if kernelURL != expected { t.Errorf("unexpected kernel URL: got %s, want %s", kernelURL, expected) } // Test serving kernel for host architecture (falls back to "host" key) - hostKernelURL, err := ifs.ServeKernel(env.HostArchitecture()) + hostKernelURL, err := ifs.ServeKernel(env.HostArchitecture(), "") if err != nil { t.Fatalf("unexpected error serving host kernel: %v", err) } @@ -777,7 +927,7 @@ func TestServeKernel(t *testing.T) { } // Test serving kernel for unsupported architecture returns empty string - noKernelURL, err := ifs.ServeKernel("ppc64le") + noKernelURL, err := ifs.ServeKernel("ppc64le", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -786,7 +936,7 @@ func TestServeKernel(t *testing.T) { } // Test idempotency - serving same arch again returns same URL - kernelURLAgain, err := ifs.ServeKernel("aarch64") + kernelURLAgain, err := ifs.ServeKernel("aarch64", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -805,9 +955,9 @@ func TestServeKernelNoKernelConfigured(t *testing.T) { ifs := &imageFileSystem{ log: logger, - isoFiles: map[string]*baseIso{}, - initramfsFiles: map[string]*baseInitramfs{}, - kernelFiles: map[string]*baseKernel{}, + isoFiles: map[streamArchKey]*baseIso{}, + initramfsFiles: map[streamArchKey]*baseInitramfs{}, + kernelFiles: map[streamArchKey]*baseKernel{}, baseURL: baseUrl, keys: map[string]string{}, images: map[string]*imageFile{}, @@ -815,7 +965,7 @@ func TestServeKernelNoKernelConfigured(t *testing.T) { } // With no kernel files, ServeKernel should return empty string - kernelURL, err := ifs.ServeKernel("x86_64") + kernelURL, err := ifs.ServeKernel("x86_64", "") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/imageprovider/rhcos.go b/pkg/imageprovider/rhcos.go index 5e626577..7475211d 100644 --- a/pkg/imageprovider/rhcos.go +++ b/pkg/imageprovider/rhcos.go @@ -87,15 +87,23 @@ func (ip *rhcosImageProvider) buildIgnitionConfig(networkData imageprovider.Netw } func imageKey(data imageprovider.ImageData) string { - return fmt.Sprintf("%s-%s-%s-%s.%s", + return fmt.Sprintf("%s-%s-%s-%s-%s.%s", data.ImageMetadata.Namespace, data.ImageMetadata.Name, data.ImageMetadata.UID, + streamFromImageData(data), data.Architecture, data.Format, ) } +func streamFromImageData(data imageprovider.ImageData) string { + if data.ImageMetadata != nil && data.ImageMetadata.Labels != nil { + return data.ImageMetadata.Labels["coreos.openshift.io/stream"] + } + return "" +} + func (ip *rhcosImageProvider) BuildImage(data imageprovider.ImageData, networkData imageprovider.NetworkData, log logr.Logger) (imageprovider.GeneratedImage, error) { generated := imageprovider.GeneratedImage{} ignitionConfig, err := ip.buildIgnitionConfig(networkData, data.ImageMetadata.Name) @@ -103,7 +111,9 @@ func (ip *rhcosImageProvider) BuildImage(data imageprovider.ImageData, networkDa return generated, err } - url, err := ip.ImageHandler.ServeImage(imageKey(data), data.Architecture, ignitionConfig, + stream := streamFromImageData(data) + + url, err := ip.ImageHandler.ServeImage(imageKey(data), data.Architecture, stream, ignitionConfig, data.Format == metal3.ImageFormatInitRD, false) if errors.As(err, &imagehandler.InvalidBaseImageError{}) { return generated, imageprovider.BuildInvalidError(err) @@ -114,7 +124,7 @@ func (ip *rhcosImageProvider) BuildImage(data imageprovider.ImageData, networkDa generated.ImageURL = url if data.Format == metal3.ImageFormatInitRD { - kernelURL, err := ip.ImageHandler.ServeKernel(data.Architecture) + kernelURL, err := ip.ImageHandler.ServeKernel(data.Architecture, stream) if err != nil { return generated, err } @@ -123,31 +133,44 @@ func (ip *rhcosImageProvider) BuildImage(data imageprovider.ImageData, networkDa } generated.KernelURL = kernelURL - // Override the rootfs URL for non-host architectures. Ironic's global - // kernel_append_params contains a rootfs URL for the host architecture. - // For other architectures we need to point to the arch-specific rootfs. - if ip.EnvInputs.IronicRootfsURL != "" && data.Architecture != env.HostArchitecture() { - archRootfsURL := archSpecificURL(ip.EnvInputs.IronicRootfsURL, data.Architecture) - generated.ExtraKernelParams = "coreos.live.rootfs_url=" + archRootfsURL + // Set the rootfs URL for every node. The stream and architecture + // determine which rootfs image is used (e.g. rhel-9 vs rhel-10, + // x86_64 vs aarch64). + if ip.EnvInputs.IronicRootfsURL != "" { + rootfsURL := streamArchSpecificURL(ip.EnvInputs.IronicRootfsURL, stream, data.Architecture) + generated.ExtraKernelParams = "coreos.live.rootfs_url=" + rootfsURL } } return generated, nil } -// archSpecificURL transforms a base URL like -// "http://host:port/images/ironic-python-agent.rootfs" into an arch-specific -// URL like "http://host:port/images/ironic-python-agent_aarch64.rootfs". -// Preserves query parameters and URL fragments. -func archSpecificURL(baseURL, arch string) string { +// streamArchSpecificURL transforms a base URL like +// "http://host:port/images/ironic-python-agent.rootfs" into a stream- and/or +// arch-specific URL. The stream suffix (e.g. "-rhel-10") is added when stream +// is non-empty, and the arch suffix (e.g. "_aarch64") is added when the +// architecture differs from the host. Examples: +// +// stream="rhel-10", arch=host → ironic-python-agent-rhel-10.rootfs +// stream="", arch=arm64 → ironic-python-agent_aarch64.rootfs +// stream="rhel-10", arch=arm64 → ironic-python-agent-rhel-10_aarch64.rootfs +func streamArchSpecificURL(baseURL, stream, arch string) string { u, err := url.Parse(baseURL) if err != nil { - // Fallback for unparseable URLs - shouldn't happen in practice return baseURL } + if arch == "" { + arch = env.HostArchitecture() + } ext := path.Ext(u.Path) base := strings.TrimSuffix(u.Path, ext) - u.Path = fmt.Sprintf("%s_%s%s", base, arch, ext) + if stream != "" { + base = fmt.Sprintf("%s-%s", base, stream) + } + if arch != env.HostArchitecture() { + base = fmt.Sprintf("%s_%s", base, arch) + } + u.Path = base + ext return u.String() }