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" 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) + } + }) + } +} 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) + } + }) + } +}