Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Schutzfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"common": {
"rngseed": 2026051900,
"rngseed": 2026052802,
"dependencies": {
"bootc-image-builder": {
"ref": "quay.io/centos-bootc/bootc-image-builder@sha256:9893e7209e5f449b86ababfd2ee02a58cca2e5990f77b06c3539227531fc8120"
Expand Down
94 changes: 94 additions & 0 deletions cmd/check-host-config/check/container_embedding.go
Original file line number Diff line number Diff line change
@@ -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()
}
173 changes: 173 additions & 0 deletions cmd/check-host-config/check/container_embedding_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
74 changes: 74 additions & 0 deletions cmd/check-host-config/check/podman_network_backend.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading