From 8d07e6955ae1b50a3271996e060b30407b39ebcb Mon Sep 17 00:00:00 2001 From: Daniel Erez Date: Tue, 31 Mar 2026 00:59:42 +0300 Subject: [PATCH] AGENT-1312: Add manifests and mapping file to the release bundle image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added ImageSetConfiguration (imageset.yaml) and Mapping file (mapping.txt) to the release bundle (both are generated by oc-mirror). Example of the image content: / ├── manifests/ │ └── imageset.yaml # ImageSetConfiguration (generated by oc-mirror) └── mirror/ └── mapping.txt # Image mapping output (generated by oc-mirror) --- bundle/Dockerfile.bundle | 13 ++++ pkg/asset/data/data_iso.go | 17 ++++++ pkg/registry/registry.go | 2 +- pkg/release/release.go | 44 +++++++++++++- pkg/release/release_test.go | 61 +++++++++++++++++++ pkg/releasebundle/releasebundle.go | 80 ++++++++++++++++++++++--- pkg/releasebundle/releasebundle_test.go | 60 ++++++++++++++++--- 7 files changed, 260 insertions(+), 17 deletions(-) diff --git a/bundle/Dockerfile.bundle b/bundle/Dockerfile.bundle index 23332b16..ba7ba527 100644 --- a/bundle/Dockerfile.bundle +++ b/bundle/Dockerfile.bundle @@ -1,2 +1,15 @@ # This Dockerfile is used to build the release bundle image. FROM scratch + +COPY imageset.yaml /manifests/imageset.yaml +COPY mapping.txt /mirror/mapping.txt + +LABEL com.redhat.component="openshift-appliance-release-bundle-container" \ + name="openshift-appliance-release-bundle" \ + summary="A release bundle for OpenShift Appliance" \ + description="A release bundle for OpenShift Appliance" \ + io.k8s.display-name="openshift-appliance-release-bundle" \ + io.k8s.description="A release bundle for OpenShift Appliance" \ + io.openshift.tags="openshift,appliance,installer,agent" \ + vendor="Red Hat, Inc." \ + url="https://github.com/openshift/appliance" diff --git a/pkg/asset/data/data_iso.go b/pkg/asset/data/data_iso.go index e6abfff3..19c8578a 100644 --- a/pkg/asset/data/data_iso.go +++ b/pkg/asset/data/data_iso.go @@ -13,6 +13,7 @@ import ( "github.com/openshift/appliance/pkg/registry" "github.com/openshift/appliance/pkg/release" "github.com/openshift/appliance/pkg/releasebundle" + "github.com/openshift/appliance/pkg/templates" "github.com/openshift/installer/pkg/asset" "github.com/sirupsen/logrus" ) @@ -114,10 +115,26 @@ func (a *DataISO) Generate(dependencies asset.Parents) error { return log.StopSpinner(spinner, err) } + imageSetPath := templates.GetFilePathByTemplate(consts.ImageSetTemplateFile, envConfig.TempDir) + mappingBytes, err := release.FindMappingFileInMirrorWorkspace(filepath.Join(envConfig.TempDir, "oc-mirror")) + if err != nil { + return log.StopSpinner(spinner, err) + } + if len(mappingBytes) == 0 { + // Real oc-mirror runs often omit mapping.txt in the workspace; dry-run generates it (see GetMappingFile). + logrus.Debug("mapping.txt not found under oc-mirror workspace; running oc mirror dry-run to produce it for the release bundle") + mappingBytes, err = r.GetMappingFile() + if err != nil { + return log.StopSpinner(spinner, fmt.Errorf("generate mapping.txt for release bundle: %w", err)) + } + } + // Build and push release bundle image bundle := releasebundle.NewBundle(releasebundle.BundleConfig{ Port: swag.IntValue(applianceConfig.Config.ImageRegistry.Port), ReleaseVersion: releaseVersion, + ImageSetPath: imageSetPath, + MappingBytes: mappingBytes, }) if err = bundle.Push(); err != nil { return log.StopSpinner(spinner, err) diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index ae6b671c..46d31283 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -297,7 +297,7 @@ func CopyRegistryImageIfNeeded(envConfig *config.EnvConfig, applianceConfig *con } else { // Pull the source registry image (docker-registry from OCP release or from appliance config) // and copy it to dir format to preserve digests - logrus.Infof("Copying registry image from %s to %s", sourceRegistryUri, consts.RegistryImage) + logrus.Debugf("Copying registry image from %s to %s", sourceRegistryUri, consts.RegistryImage) if err := skopeo.NewSkopeo(nil).CopyToFile( sourceRegistryUri, consts.RegistryImage, diff --git a/pkg/release/release.go b/pkg/release/release.go index 84d8ebd7..95d40244 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -2,6 +2,7 @@ package release import ( "fmt" + "io/fs" "os" "path" "path/filepath" @@ -183,7 +184,7 @@ func (r *release) copyOutputYamls(ocMirrorDir string, enableInteractiveFlow *boo if err != nil { return err } - + // Iterate over all yaml files and replace the localhost with the internal registry URI for _, yamlPath := range yamlPaths { logrus.Debugf("Copying ymals from oc-mirror output: %s", yamlPath) @@ -281,3 +282,44 @@ func (r *release) GetMappingFile() ([]byte, error) { return r.OSInterface.ReadFile(mappingFilePath) } + +// FindMappingFileInMirrorWorkspace returns the contents of the first mapping.txt found under root +// (typically envConfig.TempDir/oc-mirror after a real oc mirror run). If root is missing or no +// mapping file exists, it returns (nil, nil). +func FindMappingFileInMirrorWorkspace(root string) ([]byte, error) { + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if !info.IsDir() { + return nil, nil + } + // Same layout as dry-run output (see GetMappingFile); real mirror may or may not write this path. + prio := filepath.Join(root, "working-dir", "dry-run", consts.OcMirrorMappingFileName) + if data, err := os.ReadFile(prio); err == nil { + return data, nil + } else if !os.IsNotExist(err) { + return nil, err + } + var found string + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !d.IsDir() && d.Name() == consts.OcMirrorMappingFileName { + found = p + return fs.SkipAll + } + return nil + }) + if err != nil { + return nil, err + } + if found == "" { + return nil, nil + } + return os.ReadFile(found) +} diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go index 0d7e09b9..d6a67215 100644 --- a/pkg/release/release_test.go +++ b/pkg/release/release_test.go @@ -131,6 +131,67 @@ var _ = Describe("Test Release", func() { }) }) +func TestFindMappingFileInMirrorWorkspace(t *testing.T) { + t.Run("finds nested mapping.txt", func(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + want := "x=y\n" + if err := os.WriteFile(filepath.Join(sub, "mapping.txt"), []byte(want), 0o644); err != nil { + t.Fatal(err) + } + b, err := FindMappingFileInMirrorWorkspace(dir) + if err != nil { + t.Fatal(err) + } + if string(b) != want { + t.Fatalf("got %q want %q", b, want) + } + }) + t.Run("missing root returns nil", func(t *testing.T) { + b, err := FindMappingFileInMirrorWorkspace(filepath.Join(t.TempDir(), "nope")) + if err != nil { + t.Fatal(err) + } + if b != nil { + t.Fatal("expected nil bytes") + } + }) + t.Run("empty tree returns nil", func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "x"), 0o755); err != nil { + t.Fatal(err) + } + b, err := FindMappingFileInMirrorWorkspace(dir) + if err != nil { + t.Fatal(err) + } + if b != nil { + t.Fatal("expected nil bytes") + } + }) + t.Run("finds working-dir/dry-run/mapping.txt", func(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "working-dir", "dry-run") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + want := "registry/a=b\n" + if err := os.WriteFile(filepath.Join(sub, "mapping.txt"), []byte(want), 0o644); err != nil { + t.Fatal(err) + } + b, err := FindMappingFileInMirrorWorkspace(dir) + if err != nil { + t.Fatal(err) + } + if string(b) != want { + t.Fatalf("got %q want %q", b, want) + } + }) +} + func TestRelease(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "release_test") diff --git a/pkg/releasebundle/releasebundle.go b/pkg/releasebundle/releasebundle.go index c59a1e5f..7ba9c94e 100644 --- a/pkg/releasebundle/releasebundle.go +++ b/pkg/releasebundle/releasebundle.go @@ -4,20 +4,22 @@ import ( "fmt" "os" "path/filepath" + "strconv" "github.com/openshift/appliance/pkg/executer" "github.com/pkg/errors" ) -const ( - bundleBuildCmd = "podman build -f %s -t %s %s" - bundlePushCmd = "podman push --tls-verify=false %s" -) +const bundlePushCmd = "podman push --tls-verify=false %s" type BundleConfig struct { Executer executer.Executer Port int ReleaseVersion string + // ImageSetPath is the absolute path to the rendered imageset.yaml used for oc mirror. + ImageSetPath string + // MappingBytes is oc-mirror mapping.txt content (may be nil if not produced). + MappingBytes []byte } type Bundle struct { @@ -32,14 +34,51 @@ func NewBundle(config BundleConfig) *Bundle { } func (b *Bundle) Push() error { - dockerfilePath, ctx, err := resolveDockerfile() + if b.ImageSetPath == "" { + return errors.New("bundle: ImageSetPath is required") + } + dockerfileSrc, err := readBundleDockerfile() if err != nil { return err } + stagedir, err := os.MkdirTemp("", "appliance-release-bundle-*") + if err != nil { + return errors.Wrap(err, "create bundle staging dir") + } + defer os.RemoveAll(stagedir) + + dockerfileDest := filepath.Join(stagedir, "Dockerfile.bundle") + if err := os.WriteFile(dockerfileDest, dockerfileSrc, 0o644); err != nil { + return errors.Wrap(err, "write staged Dockerfile.bundle") + } + + imageSetSrc, err := os.ReadFile(b.ImageSetPath) + if err != nil { + return errors.Wrap(err, "read imageset for bundle") + } + if err := os.WriteFile(filepath.Join(stagedir, "imageset.yaml"), imageSetSrc, 0o644); err != nil { + return errors.Wrap(err, "write staged imageset.yaml") + } + + mapping := b.MappingBytes + if len(mapping) == 0 { + mapping = []byte("# mapping.txt was not found under the oc-mirror workspace\n") + } + if err := os.WriteFile(filepath.Join(stagedir, "mapping.txt"), mapping, 0o644); err != nil { + return errors.Wrap(err, "write staged mapping.txt") + } + tag := Tag(b.ReleaseVersion) imageRef := registryImageRef(b.Port, tag) - buildCmd := fmt.Sprintf(bundleBuildCmd, dockerfilePath, imageRef, ctx) + bundleVer := b.ReleaseVersion + if bundleVer == "" { + bundleVer = "unknown" + } + buildCmd := fmt.Sprintf( + "podman build --build-arg BUNDLE_VERSION=%s --build-arg BUNDLE_RELEASE=1 -f %s -t %s %s", + strconv.Quote(bundleVer), dockerfileDest, imageRef, stagedir, + ) if _, err := b.Executer.Execute(buildCmd); err != nil { return errors.Wrap(err, "build release bundle image") } @@ -52,13 +91,40 @@ func (b *Bundle) Push() error { return nil } +func readBundleDockerfile() ([]byte, error) { + path, err := bundleDockerfileAbsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrap(err, "read bundle Dockerfile.bundle") + } + return data, nil +} + +func bundleDockerfileAbsPath() (string, error) { + path, _, err := resolveDockerfile() + if err != nil { + return "", err + } + if filepath.IsAbs(path) { + return path, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(cwd, path), nil +} + // registryImageRef is the image reference used for podman build/push against the local registry // during appliance data ISO generation (must stay aligned with oc mirror localhost layout). func registryImageRef(port int, tag string) string { return fmt.Sprintf("127.0.0.1:%d/%s:%s", port, ImageRepository, tag) } -// resolveDockerfile returns paths for podman build: Dockerfile path and build context directory. +// resolveDockerfile returns paths for locating Dockerfile.bundle (path may be relative to cwd). func resolveDockerfile() (dockerfilePath, contextDir string, err error) { candidates := []struct { dockerfile string diff --git a/pkg/releasebundle/releasebundle_test.go b/pkg/releasebundle/releasebundle_test.go index eeec29b5..733ee62f 100644 --- a/pkg/releasebundle/releasebundle_test.go +++ b/pkg/releasebundle/releasebundle_test.go @@ -33,14 +33,32 @@ func writeBundleDockerfile(t *testing.T, dir string) { if err := os.MkdirAll(bundleDir, os.ModePerm); err != nil { t.Fatalf("mkdir bundle dir: %v", err) } - if err := os.WriteFile(filepath.Join(bundleDir, "Dockerfile.bundle"), []byte("FROM scratch\n"), 0o644); err != nil { + content := `FROM scratch +ARG BUNDLE_VERSION=unknown +ARG BUNDLE_RELEASE=1 +COPY imageset.yaml /manifests/imageset.yaml +COPY mapping.txt /mirror/mapping.txt +COPY Dockerfile.bundle /root/buildinfo/Dockerfile +LABEL version=$BUNDLE_VERSION +` + if err := os.WriteFile(filepath.Join(bundleDir, "Dockerfile.bundle"), []byte(content), 0o644); err != nil { t.Fatalf("write bundle dockerfile: %v", err) } } +func writeImageSet(t *testing.T, dir string) string { + t.Helper() + p := filepath.Join(dir, "imageset.yaml") + if err := os.WriteFile(p, []byte("kind: ImageSetConfiguration\napiVersion: mirror.openshift.io/v1alpha2\n"), 0o644); err != nil { + t.Fatalf("write imageset: %v", err) + } + return p +} + func TestBundlePush(t *testing.T) { wd := t.TempDir() writeBundleDockerfile(t, wd) + imageSetPath := writeImageSet(t, wd) chdir(t, wd) ctrl := gomock.NewController(t) @@ -49,13 +67,23 @@ func TestBundlePush(t *testing.T) { const port = 5005 tag := Tag("4.22.0-0.ci-2026-03-23-012741") imageRef := registryImageRef(port, tag) - mockExec.EXPECT().Execute("podman build -f bundle/Dockerfile.bundle -t " + imageRef + " bundle").Return("", nil) - mockExec.EXPECT().Execute("podman push --tls-verify=false " + imageRef).Return("", nil) + mockExec.EXPECT().Execute(gomock.Any()).DoAndReturn(func(cmd string) (string, error) { + if !strings.Contains(cmd, "podman build") || !strings.Contains(cmd, imageRef) { + t.Fatalf("unexpected build cmd: %q", cmd) + } + if !strings.Contains(cmd, "--build-arg BUNDLE_VERSION=") { + t.Fatalf("expected BUNDLE_VERSION build-arg in cmd: %q", cmd) + } + return "", nil + }) + mockExec.EXPECT().Execute("podman push --tls-verify=false "+imageRef).Return("", nil) b := NewBundle(BundleConfig{ Executer: mockExec, Port: port, ReleaseVersion: "4.22.0-0.ci-2026-03-23-012741", + ImageSetPath: imageSetPath, + MappingBytes: []byte("registry.example/a:b=localhost:5005/foo/bar:b\n"), }) if err := b.Push(); err != nil { @@ -66,20 +94,20 @@ func TestBundlePush(t *testing.T) { func TestBundlePushBuildFails(t *testing.T) { wd := t.TempDir() writeBundleDockerfile(t, wd) + imageSetPath := writeImageSet(t, wd) chdir(t, wd) ctrl := gomock.NewController(t) mockExec := executer.NewMockExecuter(ctrl) const port = 5005 - tag := Tag("4.20.5-x86_64") - imageRef := registryImageRef(port, tag) - mockExec.EXPECT().Execute("podman build -f bundle/Dockerfile.bundle -t " + imageRef + " bundle").Return("", errors.New("boom")) + mockExec.EXPECT().Execute(gomock.Any()).Return("", errors.New("boom")) b := NewBundle(BundleConfig{ Executer: mockExec, Port: port, ReleaseVersion: "4.20.5-x86_64", + ImageSetPath: imageSetPath, }) err := b.Push() @@ -95,6 +123,7 @@ func TestBundlePushBuildFails(t *testing.T) { func TestBundlePushPushFails(t *testing.T) { wd := t.TempDir() writeBundleDockerfile(t, wd) + imageSetPath := writeImageSet(t, wd) chdir(t, wd) ctrl := gomock.NewController(t) @@ -103,13 +132,14 @@ func TestBundlePushPushFails(t *testing.T) { const port = 5005 tag := Tag("4.20.5-x86_64") imageRef := registryImageRef(port, tag) - mockExec.EXPECT().Execute("podman build -f bundle/Dockerfile.bundle -t " + imageRef + " bundle").Return("", nil) - mockExec.EXPECT().Execute("podman push --tls-verify=false " + imageRef).Return("", errors.New("push boom")) + mockExec.EXPECT().Execute(gomock.Any()).Return("", nil) + mockExec.EXPECT().Execute("podman push --tls-verify=false "+imageRef).Return("", errors.New("push boom")) b := NewBundle(BundleConfig{ Executer: mockExec, Port: port, ReleaseVersion: "4.20.5-x86_64", + ImageSetPath: imageSetPath, }) err := b.Push() @@ -122,6 +152,20 @@ func TestBundlePushPushFails(t *testing.T) { } } +func TestBundlePushMissingImageSetPath(t *testing.T) { + wd := t.TempDir() + writeBundleDockerfile(t, wd) + chdir(t, wd) + + b := NewBundle(BundleConfig{ + Port: 5005, + ReleaseVersion: "4.20.0", + }) + if err := b.Push(); err == nil || !strings.Contains(err.Error(), "ImageSetPath") { + t.Fatalf("expected ImageSetPath error, got %v", err) + } +} + func TestResolveDockerfileNotFound(t *testing.T) { wd := t.TempDir() chdir(t, wd)