deb-bootc-image-builder/bib/cmd/bootc-image-builder/main_test.go
2025-08-11 08:59:41 -07:00

633 lines
16 KiB
Go

package main_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/osbuild/images/pkg/arch"
"github.com/osbuild/images/pkg/bib/osinfo"
"github.com/osbuild/images/pkg/blueprint"
"github.com/osbuild/images/pkg/container"
"github.com/osbuild/images/pkg/dnfjson"
"github.com/osbuild/images/pkg/manifest"
"github.com/osbuild/images/pkg/rpmmd"
main "github.com/osbuild/bootc-image-builder/bib/cmd/bootc-image-builder"
"github.com/osbuild/bootc-image-builder/bib/internal/imagetypes"
)
func TestCanChownInPathHappy(t *testing.T) {
tmpdir := t.TempDir()
canChown, err := main.CanChownInPath(tmpdir)
require.Nil(t, err)
assert.Equal(t, canChown, true)
// no tmpfile leftover
content, err := os.ReadDir(tmpdir)
require.Nil(t, err)
assert.Equal(t, len(content), 0)
}
func TestCanChownInPathNotExists(t *testing.T) {
canChown, err := main.CanChownInPath("/does/not/exists")
assert.Equal(t, canChown, false)
assert.ErrorContains(t, err, ": no such file or directory")
}
func TestCanChownInPathCannotChange(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("cannot run as root (fchown never errors here)")
}
restore := main.MockOsGetuid(func() int {
return -2
})
defer restore()
tmpdir := t.TempDir()
canChown, err := main.CanChownInPath(tmpdir)
require.Nil(t, err)
assert.Equal(t, canChown, false)
}
type manifestTestCase struct {
config *main.ManifestConfig
imageTypes imagetypes.ImageTypes
depsolved map[string]dnfjson.DepsolveResult
containers map[string][]container.Spec
expStages map[string][]string
notExpectedStages map[string][]string
err interface{}
}
func getBaseConfig() *main.ManifestConfig {
return &main.ManifestConfig{
Architecture: arch.ARCH_X86_64,
Imgref: "testempty",
SourceInfo: &osinfo.Info{
OSRelease: osinfo.OSRelease{
ID: "fedora",
VersionID: "40",
Name: "Fedora Linux",
PlatformID: "platform:f40",
},
UEFIVendor: "fedora",
},
// We need the real path here, because we are creating real manifests
DistroDefPaths: []string{"../../data/defs"},
// RootFSType is required to create a Manifest
RootFSType: "ext4",
}
}
func getUserConfig() *main.ManifestConfig {
// add a user
pass := "super-secret-password-42"
key := "ssh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
return &main.ManifestConfig{
Architecture: arch.ARCH_X86_64,
Imgref: "testuser",
Config: &blueprint.Blueprint{
Customizations: &blueprint.Customizations{
User: []blueprint.UserCustomization{
{
Name: "tester",
Password: &pass,
Key: &key,
},
},
},
},
SourceInfo: &osinfo.Info{
OSRelease: osinfo.OSRelease{
ID: "fedora",
VersionID: "40",
Name: "Fedora Linux",
PlatformID: "platform:f40",
},
UEFIVendor: "fedora",
},
// We need the real path here, because we are creating real manifests
DistroDefPaths: []string{"../../data/defs"},
// RootFSType is required to create a Manifest
RootFSType: "ext4",
}
}
func TestManifestGenerationEmptyConfig(t *testing.T) {
baseConfig := getBaseConfig()
testCases := map[string]manifestTestCase{
"ami-base": {
config: baseConfig,
imageTypes: []string{"ami"},
},
"raw-base": {
config: baseConfig,
imageTypes: []string{"raw"},
},
"qcow2-base": {
config: baseConfig,
imageTypes: []string{"qcow2"},
},
"iso-base": {
config: baseConfig,
imageTypes: []string{"iso"},
},
"empty-config": {
config: &main.ManifestConfig{},
imageTypes: []string{"qcow2"},
err: errors.New("pipeline: no base image defined"),
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
config := main.ManifestConfig(*tc.config)
config.ImageTypes = tc.imageTypes
_, err := main.Manifest(&config)
assert.Equal(t, err, tc.err)
})
}
}
func TestManifestGenerationUserConfig(t *testing.T) {
userConfig := getUserConfig()
testCases := map[string]manifestTestCase{
"ami-user": {
config: userConfig,
imageTypes: []string{"ami"},
},
"raw-user": {
config: userConfig,
imageTypes: []string{"raw"},
},
"qcow2-user": {
config: userConfig,
imageTypes: []string{"qcow2"},
},
"iso-user": {
config: userConfig,
imageTypes: []string{"iso"},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
config := main.ManifestConfig(*tc.config)
config.ImageTypes = tc.imageTypes
_, err := main.Manifest(&config)
assert.NoError(t, err)
})
}
}
// Disk images require a container for the build/image pipelines
var containerSpec = container.Spec{
Source: "test-container",
Digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
ImageID: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
}
// diskContainers can be passed to Serialize() to get a minimal disk image
var diskContainers = map[string][]container.Spec{
"build": {
containerSpec,
},
"image": {
containerSpec,
},
"target": {
containerSpec,
},
}
// TODO: this tests at this layer is not ideal, it has too much knowledge
// over the implementation details of the "images" library and how an
// image.NewBootcDiskImage() works (i.e. what the pipeline names are and
// what key piplines to expect). These details should be tested in "images"
// and here we would just check (somehow) that image.NewBootcDiskImage()
// (or image.NewAnacondaContainerInstaller()) is called and the right
// customizations are passed. The existing layout makes this hard so this
// is fine for now but would be nice to revisit this.
func TestManifestSerialization(t *testing.T) {
// Tests that the manifest is generated without error and is serialized
// with expected key stages.
// ISOs require a container for the bootiso-tree, build packages, and packages for the anaconda-tree (with a kernel).
var isoContainers = map[string][]container.Spec{
"bootiso-tree": {
containerSpec,
},
}
isoPackages := map[string]dnfjson.DepsolveResult{
"build": {
Packages: []rpmmd.PackageSpec{
{
Name: "package",
Version: "113",
Checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
},
},
"anaconda-tree": {
Packages: []rpmmd.PackageSpec{
{
Name: "kernel",
Version: "10.11",
Checksum: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
},
{
Name: "package",
Version: "113",
Checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
},
},
}
pkgsNoBuild := map[string]dnfjson.DepsolveResult{
"anaconda-tree": {
Packages: []rpmmd.PackageSpec{
{
Name: "kernel",
Version: "10.11",
Checksum: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
},
{
Name: "package",
Version: "113",
Checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
},
},
}
baseConfig := getBaseConfig()
userConfig := getUserConfig()
testCases := map[string]manifestTestCase{
"ami-base": {
config: baseConfig,
imageTypes: []string{"ami"},
containers: diskContainers,
expStages: map[string][]string{
"build": {"org.osbuild.container-deploy"},
"image": {
"org.osbuild.bootc.install-to-filesystem",
},
},
notExpectedStages: map[string][]string{
"build": {"org.osbuild.rpm"},
"image": {
"org.osbuild.users",
},
},
},
"raw-base": {
config: baseConfig,
imageTypes: []string{"raw"},
containers: diskContainers,
expStages: map[string][]string{
"build": {"org.osbuild.container-deploy"},
"image": {
"org.osbuild.bootc.install-to-filesystem",
},
},
notExpectedStages: map[string][]string{
"build": {"org.osbuild.rpm"},
"image": {
"org.osbuild.users",
},
},
},
"qcow2-base": {
config: baseConfig,
imageTypes: []string{"qcow2"},
containers: diskContainers,
expStages: map[string][]string{
"build": {"org.osbuild.container-deploy"},
"image": {
"org.osbuild.bootc.install-to-filesystem",
},
},
notExpectedStages: map[string][]string{
"build": {"org.osbuild.rpm"},
"image": {
"org.osbuild.users",
},
},
},
"ami-user": {
config: userConfig,
imageTypes: []string{"ami"},
containers: diskContainers,
expStages: map[string][]string{
"build": {"org.osbuild.container-deploy"},
"image": {
"org.osbuild.users",
"org.osbuild.bootc.install-to-filesystem",
},
},
notExpectedStages: map[string][]string{
"build": {"org.osbuild.rpm"},
},
},
"raw-user": {
config: userConfig,
imageTypes: []string{"raw"},
containers: diskContainers,
expStages: map[string][]string{
"build": {"org.osbuild.container-deploy"},
"image": {
"org.osbuild.users", // user creation stage when we add users
"org.osbuild.bootc.install-to-filesystem",
},
},
notExpectedStages: map[string][]string{
"build": {"org.osbuild.rpm"},
},
},
"qcow2-user": {
config: userConfig,
imageTypes: []string{"qcow2"},
containers: diskContainers,
expStages: map[string][]string{
"build": {"org.osbuild.container-deploy"},
"image": {
"org.osbuild.users", // user creation stage when we add users
"org.osbuild.bootc.install-to-filesystem",
},
},
notExpectedStages: map[string][]string{
"build": {"org.osbuild.rpm"},
},
},
"iso-user": {
config: userConfig,
imageTypes: []string{"iso"},
containers: isoContainers,
depsolved: isoPackages,
expStages: map[string][]string{
"build": {"org.osbuild.rpm"},
"bootiso-tree": {"org.osbuild.skopeo"}, // adds the container to the ISO tree
},
},
"iso-nobuildpkg": {
config: userConfig,
imageTypes: []string{"iso"},
containers: isoContainers,
depsolved: pkgsNoBuild,
err: "serialization not started",
},
"iso-nocontainer": {
config: userConfig,
imageTypes: []string{"iso"},
depsolved: isoPackages,
err: "missing ostree, container, or ospipeline parameters in ISO tree pipeline",
},
"ami-nocontainer": {
config: userConfig,
imageTypes: []string{"ami"},
// errors come from BuildrootFromContainer()
// TODO: think about better error and testing here (not the ideal layer or err msg)
err: "serialization not started",
},
"raw-nocontainer": {
config: userConfig,
imageTypes: []string{"raw"},
// errors come from BuildrootFromContainer()
// TODO: think about better error and testing here (not the ideal layer or err msg)
err: "serialization not started",
},
"qcow2-nocontainer": {
config: userConfig,
imageTypes: []string{"qcow2"},
// errors come from BuildrootFromContainer()
// TODO: think about better error and testing here (not the ideal layer or err msg)
err: "serialization not started",
},
}
// Use an empty config: only the imgref is required
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
config := main.ManifestConfig(*tc.config)
config.ImageTypes = tc.imageTypes
mf, err := main.Manifest(&config)
assert.NoError(err) // this isn't the error we're testing for
if tc.err != nil {
assert.PanicsWithValue(tc.err, func() {
_, err := mf.Serialize(tc.depsolved, tc.containers, nil, nil)
assert.NoError(err)
})
} else {
manifestJson, err := mf.Serialize(tc.depsolved, tc.containers, nil, nil)
assert.NoError(err)
assert.NoError(checkStages(manifestJson, tc.expStages, tc.notExpectedStages))
}
})
}
{
// this one panics with a typed error and needs to be tested separately from the above (PanicsWithError())
t.Run("iso-nopkgs", func(t *testing.T) {
assert := assert.New(t)
config := main.ManifestConfig(*userConfig)
config.ImageTypes, _ = imagetypes.New("iso")
manifest, err := main.Manifest(&config)
assert.NoError(err) // this isn't the error we're testing for
expError := "package \"kernel\" not found in the PackageSpec list"
assert.PanicsWithError(expError, func() {
_, err := manifest.Serialize(nil, isoContainers, nil, nil)
assert.NoError(err)
})
})
}
}
// simplified representation of a manifest
type testManifest struct {
Pipelines []pipeline `json:"pipelines"`
}
type pipeline struct {
Name string `json:"name"`
Stages []stage `json:"stages"`
}
type stage struct {
Type string `json:"type"`
}
func checkStages(serialized manifest.OSBuildManifest, pipelineStages map[string][]string, missingStages map[string][]string) error {
mf := &testManifest{}
if err := json.Unmarshal(serialized, mf); err != nil {
return err
}
pipelineMap := map[string]pipeline{}
for _, pl := range mf.Pipelines {
pipelineMap[pl.Name] = pl
}
for plname, stages := range pipelineStages {
pl, found := pipelineMap[plname]
if !found {
return fmt.Errorf("pipeline %q not found", plname)
}
stageMap := map[string]bool{}
for _, stage := range pl.Stages {
stageMap[stage.Type] = true
}
for _, stage := range stages {
if _, found := stageMap[stage]; !found {
return fmt.Errorf("pipeline %q - stage %q - not found", plname, stage)
}
}
}
for plname, stages := range missingStages {
pl, found := pipelineMap[plname]
if !found {
return fmt.Errorf("pipeline %q not found", plname)
}
stageMap := map[string]bool{}
for _, stage := range pl.Stages {
stageMap[stage.Type] = true
}
for _, stage := range stages {
if _, found := stageMap[stage]; found {
return fmt.Errorf("pipeline %q - stage %q - found (but should not be)", plname, stage)
}
}
}
return nil
}
func mockOsArgs(new []string) (restore func()) {
saved := os.Args
os.Args = append([]string{"argv0"}, new...)
return func() {
os.Args = saved
}
}
func addRunLog(rootCmd *cobra.Command, runeCall *string) {
for _, cmd := range rootCmd.Commands() {
cmd.RunE = func(cmd *cobra.Command, args []string) error {
callStr := fmt.Sprintf("<%v>: %v", cmd.Name(), strings.Join(args, ","))
if *runeCall != "" {
panic(fmt.Sprintf("runE called with %v but already called before: %v", callStr, *runeCall))
}
*runeCall = callStr
return nil
}
}
}
func TestCobraCmdline(t *testing.T) {
for _, tc := range []struct {
cmdline []string
expectedCall string
}{
// trivial: cmd is given explicitly
{
[]string{"manifest", "quay.io..."},
"<manifest>: quay.io...",
},
{
[]string{"build", "quay.io..."},
"<build>: quay.io...",
},
{
[]string{"version", "quay.io..."},
"<version>: quay.io...",
},
// implicit: no cmd like build/manifest defaults to build
{
[]string{"--local", "quay.io..."},
"<build>: quay.io...",
},
{
[]string{"quay.io..."},
"<build>: quay.io...",
},
} {
var runeCall string
restore := mockOsArgs(tc.cmdline)
defer restore()
rootCmd, err := main.BuildCobraCmdline()
assert.NoError(t, err)
addRunLog(rootCmd, &runeCall)
t.Run(tc.expectedCall, func(t *testing.T) {
err = rootCmd.Execute()
assert.NoError(t, err)
assert.Equal(t, runeCall, tc.expectedCall)
})
}
}
func TestCobraCmdlineVerbose(t *testing.T) {
for _, tc := range []struct {
cmdline []string
expectedProgress string
expectedLogrusLevel logrus.Level
}{
{
[]string{"quay.io..."},
"auto",
logrus.ErrorLevel,
},
{
[]string{"-v", "quay.io..."},
"verbose",
logrus.InfoLevel,
},
} {
restore := mockOsArgs(tc.cmdline)
defer restore()
rootCmd, err := main.BuildCobraCmdline()
assert.NoError(t, err)
// collect progressFlag value
var progressFlag string
for _, cmd := range rootCmd.Commands() {
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if progressFlag != "" {
t.Error("progressFlag set twice")
}
progressFlag, err = cmd.Flags().GetString("progress")
assert.NoError(t, err)
return nil
}
}
t.Run(tc.expectedProgress, func(t *testing.T) {
err = rootCmd.Execute()
assert.NoError(t, err)
assert.Equal(t, tc.expectedProgress, progressFlag)
assert.Equal(t, tc.expectedLogrusLevel, logrus.GetLevel())
})
}
}