diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2b446badd..be060b4eb2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -101,6 +101,9 @@ jobs: - name: Run unit tests for bib container run: sudo go test ./pkg/bib/container/... --fail-if-podman-missing + - name: Run tests for bootc container + run: sudo go test ./pkg/distro/bootc/... + unit-tests-cs: strategy: matrix: diff --git a/pkg/bib/container/container.go b/pkg/bib/container/container.go index 59a7aaae77..d94ddbf2cc 100644 --- a/pkg/bib/container/container.go +++ b/pkg/bib/container/container.go @@ -2,7 +2,6 @@ package container import ( "encoding/json" - "errors" "fmt" "os" "os/exec" @@ -67,11 +66,7 @@ func New(ref string) (*Container, error) { // not all containers set {{.Architecture}} so fallback c.arch, err = findContainerArchInspect(c.id, ref) if err != nil { - var err2 error - c.arch, err2 = findContainerArchUname(c.id, ref) - if err2 != nil { - return nil, errors.Join(err, err2) - } + return nil, err } /* #nosec G204 */ @@ -185,9 +180,9 @@ func (c *Container) DefaultRootfsType() (string, error) { return fsType, nil } -func findContainerArchInspect(cntId, ref string) (string, error) { +func findImageIdFor(cntId, ref string) (string, error) { /* #nosec G204 */ - output, err := exec.Command("podman", "inspect", "-f", "{{.Architecture}}", cntId).Output() + output, err := exec.Command("podman", "inspect", "-f", "{{.Image}}", cntId).Output() if err != nil { if err, ok := err.(*exec.ExitError); ok { return "", fmt.Errorf("inspecting container %q failed: %w\nstderr:\n%s", ref, err, err.Stderr) @@ -197,14 +192,22 @@ func findContainerArchInspect(cntId, ref string) (string, error) { return strings.TrimSpace(string(output)), nil } -func findContainerArchUname(cntId, ref string) (string, error) { +func findContainerArchInspect(cntId, ref string) (string, error) { + // get image id first, then get the arch from the image, + // it seems this is the most reliable way to get the + // architecture + imageId, err := findImageIdFor(cntId, ref) + if err != nil { + return "", err + } + /* #nosec G204 */ - output, err := exec.Command("podman", "exec", cntId, "uname", "-m").Output() + output, err := exec.Command("podman", "inspect", "-f", "{{.Architecture}}", imageId).Output() if err != nil { if err, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("running 'uname -m' from container %q failed: %w\nstderr:\n%s", cntId, err, err.Stderr) + return "", fmt.Errorf("inspecting container %q failed: %w\nstderr:\n%s", ref, err, err.Stderr) } - return "", fmt.Errorf("running 'uname -m' from container %q failed with generic error: %w", cntId, err) + return "", fmt.Errorf("inspecting %s container failed with generic error: %w", ref, err) } return strings.TrimSpace(string(output)), nil } diff --git a/pkg/distro/bootc/bootctest/bootctest.go b/pkg/distro/bootc/bootctest/bootctest.go new file mode 100644 index 0000000000..e3daa5c51a --- /dev/null +++ b/pkg/distro/bootc/bootctest/bootctest.go @@ -0,0 +1,105 @@ +package bootctest + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/osbuild/images/internal/randutil" +) + +func makeOsRelease(t *testing.T, buildDir string) { + osRelease := ` +NAME="bootc-fake-name" +ID="bootc-fake" +VERSION_ID="1" +` + + osReleasePath := filepath.Join(buildDir, "etc/os-release") + err := os.MkdirAll(filepath.Dir(osReleasePath), 0755) + require.NoError(t, err) + //nolint:gosec + err = os.WriteFile(osReleasePath, []byte(osRelease), 0644) + require.NoError(t, err) +} + +func makeBootcInstallToml(t *testing.T, buildDir string) { + installToml := ` +[install] +filesystem = [ + { mountpoint = "/", type = "xfs", size = "10 GiB" }, + { mountpoint = "/boot", type = "ext4", size = "1 GiB" }, +] +` + + installTomlPath := filepath.Join(buildDir, "usr/lib/bootc/install/99-fedora-install.toml") + err := os.MkdirAll(filepath.Dir(installTomlPath), 0755) + require.NoError(t, err) + //nolint:gosec + err = os.WriteFile(installTomlPath, []byte(installToml), 0644) + require.NoError(t, err) +} + +func makeFakeBinaries(t *testing.T, buildDir string) { + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok) + currentDir := filepath.Dir(currentFile) + + destDir := fmt.Sprintf("DESTDIR=%s", filepath.Join(buildDir, "/usr/bin")) + //nolint:gosec + output, err := exec.Command("make", "-C", filepath.Join(currentDir, "exe"), destDir).CombinedOutput() + require.NoError(t, err, string(output)) +} + +func makeContainerfile(t *testing.T, buildDir string) { + var fakeBootcCnt = ` +FROM scratch +COPY etc /etc +COPY usr/bin /usr/bin +COPY usr/lib/bootc/install /usr/lib/bootc/install +` + + cntFilePath := filepath.Join(buildDir, "Containerfile") + //nolint:gosec + err := os.WriteFile(cntFilePath, []byte(fakeBootcCnt), 0644) + require.NoError(t, err) +} + +func makeFakeContainerImage(t *testing.T, buildDir, purpose string) string { + imgTag := fmt.Sprintf("image-builder-test-%s-%s", purpose, randutil.String(10, randutil.AsciiLower)) + //nolint:gosec + output, err := exec.Command( + "podman", "build", + "-f", filepath.Join(buildDir, "Containerfile"), + "-t", imgTag, + ).CombinedOutput() + require.NoError(t, err, string(output)) + // add cleanup + t.Cleanup(func() { + output, err := exec.Command("podman", "image", "rm", imgTag).CombinedOutput() + assert.NoError(t, err, string(output)) + }) + + return fmt.Sprintf("localhost/%s", imgTag) +} + +func NewFakeContainer(t *testing.T, purpose string) string { + t.Helper() + + buildDir := t.TempDir() + + // XXX: allow adding test specific content + makeContainerfile(t, buildDir) + makeFakeBinaries(t, buildDir) + // XXX: make os-release content configurable + makeOsRelease(t, buildDir) + makeBootcInstallToml(t, buildDir) + + return makeFakeContainerImage(t, buildDir, purpose) +} diff --git a/pkg/distro/bootc/bootctest/exe/Makefile b/pkg/distro/bootc/bootctest/exe/Makefile new file mode 100644 index 0000000000..7d933bc61f --- /dev/null +++ b/pkg/distro/bootc/bootctest/exe/Makefile @@ -0,0 +1,16 @@ +DESTDIR ?= . + +.PHONY: all clean + +all: $(DESTDIR)/bootc $(DESTDIR)/sleep + +$(DESTDIR)/bootc: *.go + mkdir -p $(DESTDIR) + CGO_ENABLED=0 go build -o $@ $< + +$(DESTDIR)/sleep: $(DESTDIR)/bootc + cd $(DESTDIR) && ln -sf bootc sleep + +clean: + rm -f $(DESTDIR)/bootc $(DESTDIR)/sleep + diff --git a/pkg/distro/bootc/bootctest/exe/bootc.go b/pkg/distro/bootc/bootctest/exe/bootc.go new file mode 100644 index 0000000000..e84b2a80ae --- /dev/null +++ b/pkg/distro/bootc/bootctest/exe/bootc.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "math" + "os" + "path/filepath" + "time" +) + +func fakeBootc() error { + if os.Args[1] != "install" || os.Args[2] != "print-configuration" { + return fmt.Errorf("unexpected bootc arguments %v", os.Args) + } + // print a sensible default configuration + fmt.Println(`{"filesystem": {"root": {"type": "ext4"}}}`) + return nil +} + +func fakeSleep() error { + if os.Args[1] != "infinity" { + return fmt.Errorf("unexpected sleep arguments %v", os.Args) + } + time.Sleep(math.MaxInt64) + return nil +} + +func main() { + var err error + switch filepath.Base(os.Args[0]) { + case "bootc": + err = fakeBootc() + case "sleep": + err = fakeSleep() + } + if err != nil { + println("error: ", err.Error()) + os.Exit(1) + } +} diff --git a/pkg/distro/bootc/integration_test.go b/pkg/distro/bootc/integration_test.go new file mode 100644 index 0000000000..5dd28b72b6 --- /dev/null +++ b/pkg/distro/bootc/integration_test.go @@ -0,0 +1,104 @@ +package bootc_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/osbuild/blueprint/pkg/blueprint" + + "github.com/osbuild/images/pkg/arch" + "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/distro/bootc" + "github.com/osbuild/images/pkg/distro/bootc/bootctest" + "github.com/osbuild/images/pkg/manifestgen" + "github.com/osbuild/images/pkg/osbuild/manifesttest" + "github.com/osbuild/images/pkg/rpmmd" +) + +func canRunIntegration(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("test needs root") + } + if _, err := exec.LookPath("podman"); err != nil { + t.Skip("test needs installed podman") + } + if _, err := exec.LookPath("systemd-detect-virt"); err != nil { + t.Skip("test needs systemd-detect-virt") + } + // exit code "0" means the container is detected + if err := exec.Command("systemd-detect-virt", "-c", "-q").Run(); err == nil { + t.Skip("test cannot run inside a container") + } +} + +func genManifest(t *testing.T, imgType distro.ImageType) string { + var bp blueprint.Blueprint + + var manifestJson bytes.Buffer + mg, err := manifestgen.New(nil, &manifestgen.Options{ + Output: &manifestJson, + OverrideRepos: []rpmmd.RepoConfig{ + {Id: "not-used", BaseURLs: []string{"not-used"}}, + }, + }) + assert.NoError(t, err) + err = mg.Generate(&bp, imgType.Arch().Distro(), imgType, imgType.Arch(), nil) + assert.NoError(t, err) + + // XXX: it would be nice to return an *osbuild.Manifest here + // and do all of this more structed, however this is not + // working currently as osbuild.NewManifestsFromBytes() cannot + // unmarshal our manifests because of: + // "unexpected source name: org.osbuild.containers-storage" + return manifestJson.String() +} + +func TestBuildContainerHandling(t *testing.T) { + canRunIntegration(t) + + imgTag := bootctest.NewFakeContainer(t, "bootc") + buildImgTag := bootctest.NewFakeContainer(t, "build") + + for _, withBuildContainer := range []bool{true, false} { + t.Run(fmt.Sprintf("build-cnt:%v", withBuildContainer), func(t *testing.T) { + distri, err := bootc.NewBootcDistro(imgTag) + require.NoError(t, err) + if withBuildContainer { + err = distri.SetBuildContainer(buildImgTag) + require.NoError(t, err) + } + + archi, err := distri.GetArch(arch.Current().String()) + require.NoError(t, err) + imgType, err := archi.GetImageType("qcow2") + assert.NoError(t, err) + + manifestJson := genManifest(t, imgType) + pipelineNames, err := manifesttest.PipelineNamesFrom([]byte(manifestJson)) + require.NoError(t, err) + buildStages, err := manifesttest.StagesForPipeline([]byte(manifestJson), "build") + require.NoError(t, err) + // the bootc container is always pulled + assert.Contains(t, manifestJson, imgTag) + if withBuildContainer { + assert.Contains(t, manifestJson, buildImgTag) + // validate that the usr/lib/bootc/install/ dir is copied + assert.Contains(t, manifestJson, "usr/lib/bootc/install/") + assert.Contains(t, buildStages, "org.osbuild.copy") + // validate that we have a "target" pipeline for raw content + assert.Contains(t, pipelineNames, "target") + } else { + assert.NotContains(t, manifestJson, buildImgTag) + assert.NotContains(t, manifestJson, "usr/lib/bootc/install/") + assert.NotContains(t, buildStages, "org.osbuild.copy") + assert.NotContains(t, pipelineNames, "target") + } + }) + } +}