From 8a394fc81a04708066e7eee59cf4bd6fcc128f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hozza?= Date: Tue, 10 Mar 2026 12:13:20 +0100 Subject: [PATCH 1/3] cmd/check-host-config: add container embedding check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that containers listed in the blueprint are actually present in the booted image's podman storage. Signed-off-by: Tomáš Hozza --- .../check/container_embedding.go | 94 ++++++++++ .../check/container_embedding_test.go | 173 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 cmd/check-host-config/check/container_embedding.go create mode 100644 cmd/check-host-config/check/container_embedding_test.go diff --git a/cmd/check-host-config/check/container_embedding.go b/cmd/check-host-config/check/container_embedding.go new file mode 100644 index 0000000000..1724b81157 --- /dev/null +++ b/cmd/check-host-config/check/container_embedding.go @@ -0,0 +1,94 @@ +package check + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/osbuild/images/internal/buildconfig" +) + +func init() { + RegisterCheck(Metadata{ + Name: "container-embedding", + RequiresBlueprint: true, + }, containerEmbeddingCheck) +} + +type podmanImage struct { + Names []string `json:"Names"` +} + +// containerNameMatches reports whether a podman image name matches the +// expected needle. Short names (without a domain/path component) are +// normalized by the container runtime: skopeo/containers-storage adds +// "docker.io/library/" (the Docker default) while locally-built images +// may get "localhost/". We check the needle against all known +// normalizations. +func containerNameMatches(podmanName, needle string) bool { + candidates := []string{needle} + nameBeforeTag := strings.SplitN(needle, ":", 2)[0] + if !strings.Contains(nameBeforeTag, "/") { + candidates = append(candidates, + "localhost/"+needle, + "docker.io/library/"+needle, + ) + } + for _, c := range candidates { + if podmanName == c || strings.HasPrefix(podmanName, c+":") { + return true + } + } + return false +} + +func containerEmbeddingCheck(meta *Metadata, config *buildconfig.BuildConfig) error { + containers := config.Blueprint.Containers + if len(containers) == 0 { + return Skip("no containers to check") + } + + stdout, _, _, err := Exec("sudo", "podman", "images", "--format", "json") + if err != nil { + return Fail("failed to list podman images:", err) + } + + var images []podmanImage + if err := json.Unmarshal(stdout, &images); err != nil { + return Fail("failed to parse podman images output:", err) + } + + for _, ctr := range containers { + // The blueprint Name, when set, is used as the local name for the + // container in the image storage (see Spec.LocalName). When empty, + // the source reference is used instead. + needle := ctr.Source + if ctr.Name != "" { + needle = ctr.Name + } + if needle == "" { + continue + } + + found := false + for _, img := range images { + for _, name := range img.Names { + if containerNameMatches(name, needle) { + found = true + break + } + } + if found { + break + } + } + + if !found { + return Fail(fmt.Sprintf("embedded container %q (source %q) not found in podman images", needle, ctr.Source)) + } + log.Printf("Container %q found in podman images\n", needle) + } + + return Pass() +} diff --git a/cmd/check-host-config/check/container_embedding_test.go b/cmd/check-host-config/check/container_embedding_test.go new file mode 100644 index 0000000000..9f996eb1f6 --- /dev/null +++ b/cmd/check-host-config/check/container_embedding_test.go @@ -0,0 +1,173 @@ +package check_test + +import ( + "errors" + "testing" + + "github.com/osbuild/blueprint/pkg/blueprint" + check "github.com/osbuild/images/cmd/check-host-config/check" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContainerEmbeddingCheck(t *testing.T) { + tests := []struct { + name string + containers []blueprint.Container + mockExec map[string]ExecResult + wantErr error + }{ + { + name: "skip when no containers", + containers: nil, + wantErr: check.ErrCheckSkipped, + }, + { + name: "pass when image name matches with tag suffix", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["registry.example.com/test:latest"]}]`), + }, + }, + }, + { + name: "pass when image name matches exactly without tag", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["registry.example.com/test"]}]`), + }, + }, + }, + { + name: "fail when container is not found", + containers: []blueprint.Container{ + {Source: "registry.example.com/missing"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["registry.example.com/other:latest"]}]`), + }, + }, + wantErr: check.ErrCheckFailed, + }, + { + name: "fail when podman command fails", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Err: errors.New("podman not found"), + }, + }, + wantErr: check.ErrCheckFailed, + }, + { + name: "pass with multiple containers", + containers: []blueprint.Container{ + {Source: "registry.example.com/first"}, + {Source: "registry.example.com/second"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["registry.example.com/first:latest"]},{"Names":["registry.example.com/second:v1"]}]`), + }, + }, + }, + { + name: "pass when custom name matches", + containers: []blueprint.Container{ + {Source: "registry.example.com/source-image", Name: "custom-name:v1"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["custom-name:v1"]}]`), + }, + }, + }, + { + name: "pass when short name is stored with docker.io/library/ prefix", + containers: []blueprint.Container{ + {Source: "registry.example.com/source-image", Name: "manifest-list-test:v1"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["docker.io/library/manifest-list-test:v1"]}]`), + }, + }, + }, + { + name: "pass when short name is stored with localhost/ prefix", + containers: []blueprint.Container{ + {Source: "registry.example.com/source-image", Name: "manifest-list-test:v1"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["localhost/manifest-list-test:v1"]}]`), + }, + }, + }, + { + name: "pass when short name without tag gets docker.io/library/ prefix and tag", + containers: []blueprint.Container{ + {Source: "registry.example.com/source-image", Name: "my-image"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["docker.io/library/my-image:latest"]}]`), + }, + }, + }, + { + name: "fail when custom name does not match", + containers: []blueprint.Container{ + {Source: "registry.example.com/source-image", Name: "custom-name:v1"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["registry.example.com/source-image:latest"]}]`), + }, + }, + wantErr: check.ErrCheckFailed, + }, + { + name: "fail when image name is only a prefix match", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman images --format json": { + Stdout: []byte(`[{"Names":["registry.example.com/testing:latest"]}]`), + }, + }, + wantErr: check.ErrCheckFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installMockExec(t, tt.mockExec) + + chk, found := check.FindCheckByName("container-embedding") + require.True(t, found, "container-embedding check not found") + + config := buildConfigWithBlueprint(func(bp *blueprint.Blueprint) { + bp.Containers = tt.containers + }) + + err := chk.Func(chk.Meta, config) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr)) + } else { + require.NoError(t, err) + } + }) + } +} From a0b00313a8c380cb4139ef0476be4c74e7266ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hozza?= Date: Tue, 10 Mar 2026 12:14:15 +0100 Subject: [PATCH 2/3] cmd/check-host-config: add podman network backend consistency check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that rootful and rootless podman report the same network backend. When containers are embedded as root into the image (the default behavior), some podman versions interpret the existing storage as a migration and fall back to 'cni' for rootful only, leaving rootless on 'netavark'. In practice, the desired behavior is that podman uses the same network backend, regardless if there is an embedded container or not. Signed-off-by: Tomáš Hozza --- .../check/podman_network_backend.go | 74 +++++++++++++ .../check/podman_network_backend_test.go | 103 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 cmd/check-host-config/check/podman_network_backend.go create mode 100644 cmd/check-host-config/check/podman_network_backend_test.go diff --git a/cmd/check-host-config/check/podman_network_backend.go b/cmd/check-host-config/check/podman_network_backend.go new file mode 100644 index 0000000000..dddc525c68 --- /dev/null +++ b/cmd/check-host-config/check/podman_network_backend.go @@ -0,0 +1,74 @@ +package check + +import ( + "encoding/json" + "log" + + "github.com/osbuild/images/internal/buildconfig" +) + +func init() { + RegisterCheck(Metadata{ + Name: "podman-network-backend", + RequiresBlueprint: true, + }, podmanNetworkBackendCheck) +} + +type podmanInfo struct { + Host struct { + NetworkBackend string `json:"networkBackend"` + } `json:"host"` +} + +func getPodmanNetworkBackend(sudo bool) (string, error) { + var stdout []byte + var err error + + if sudo { + stdout, _, _, err = Exec("sudo", "podman", "info", "--format", "json") + } else { + stdout, _, _, err = Exec("podman", "info", "--format", "json") + } + if err != nil { + return "", err + } + + var info podmanInfo + if err := json.Unmarshal(stdout, &info); err != nil { + return "", err + } + + backend := info.Host.NetworkBackend + if backend == "" { + backend = "undefined" + } + return backend, nil +} + +// podmanNetworkBackendCheck verifies that rootful and rootless podman use the +// same network backend. When containers are embedded into the image as root, +// certain podman versions may interpret the existing storage as a migration +// and fall back to 'cni' for rootful only, creating an inconsistency. +func podmanNetworkBackendCheck(meta *Metadata, config *buildconfig.BuildConfig) error { + if len(config.Blueprint.Containers) == 0 { + return Skip("no embedded containers") + } + + rootful, err := getPodmanNetworkBackend(true) + if err != nil { + return Fail("failed to get rootful podman network backend:", err) + } + log.Printf("Rootful podman network backend: %s\n", rootful) + + rootless, err := getPodmanNetworkBackend(false) + if err != nil { + return Fail("failed to get rootless podman network backend:", err) + } + log.Printf("Rootless podman network backend: %s\n", rootless) + + if rootful != rootless { + return Fail("podman network backends are inconsistent:", "rootful="+rootful, "rootless="+rootless) + } + + return Pass() +} diff --git a/cmd/check-host-config/check/podman_network_backend_test.go b/cmd/check-host-config/check/podman_network_backend_test.go new file mode 100644 index 0000000000..4fbd4a7dc8 --- /dev/null +++ b/cmd/check-host-config/check/podman_network_backend_test.go @@ -0,0 +1,103 @@ +package check_test + +import ( + "errors" + "testing" + + "github.com/osbuild/blueprint/pkg/blueprint" + check "github.com/osbuild/images/cmd/check-host-config/check" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPodmanNetworkBackendCheck(t *testing.T) { + tests := []struct { + name string + containers []blueprint.Container + mockExec map[string]ExecResult + wantErr error + }{ + { + name: "skip when no containers", + containers: nil, + wantErr: check.ErrCheckSkipped, + }, + { + name: "pass when backends match", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman info --format json": { + Stdout: []byte(`{"host":{"networkBackend":"netavark"}}`), + }, + "podman info --format json": { + Stdout: []byte(`{"host":{"networkBackend":"netavark"}}`), + }, + }, + }, + { + name: "fail when backends differ", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman info --format json": { + Stdout: []byte(`{"host":{"networkBackend":"cni"}}`), + }, + "podman info --format json": { + Stdout: []byte(`{"host":{"networkBackend":"netavark"}}`), + }, + }, + wantErr: check.ErrCheckFailed, + }, + { + name: "fail when rootless podman command fails", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman info --format json": { + Stdout: []byte(`{"host":{"networkBackend":"netavark"}}`), + }, + "podman info --format json": { + Err: errors.New("podman not found"), + }, + }, + wantErr: check.ErrCheckFailed, + }, + { + name: "fail when rootful podman command fails", + containers: []blueprint.Container{ + {Source: "registry.example.com/test"}, + }, + mockExec: map[string]ExecResult{ + "sudo podman info --format json": { + Err: errors.New("podman not found"), + }, + }, + wantErr: check.ErrCheckFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installMockExec(t, tt.mockExec) + + chk, found := check.FindCheckByName("podman-network-backend") + require.True(t, found, "podman-network-backend check not found") + + config := buildConfigWithBlueprint(func(bp *blueprint.Blueprint) { + bp.Containers = tt.containers + }) + + err := chk.Func(chk.Meta, config) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr)) + } else { + require.NoError(t, err) + } + }) + } +} From e597e3db276cfe0c982760221ae85ed22ec26e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hozza?= Date: Thu, 28 May 2026 21:41:19 +0200 Subject: [PATCH 3/3] Schutzfile: bump rngseed to force full rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make sure that the newly added checks are run on all images. Signed-off-by: Tomáš Hozza --- Schutzfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Schutzfile b/Schutzfile index 77d39362d1..6f0087a5b8 100644 --- a/Schutzfile +++ b/Schutzfile @@ -1,6 +1,6 @@ { "common": { - "rngseed": 2026051900, + "rngseed": 2026052899, "dependencies": { "bootc-image-builder": { "ref": "quay.io/centos-bootc/bootc-image-builder@sha256:9893e7209e5f449b86ababfd2ee02a58cca2e5990f77b06c3539227531fc8120"