diff --git a/pkg/imagefilter/filter.go b/pkg/imagefilter/filter.go new file mode 100644 index 0000000000..748df4b86c --- /dev/null +++ b/pkg/imagefilter/filter.go @@ -0,0 +1,92 @@ +package imagefilter + +import ( + "fmt" + "slices" + "strings" + + "github.com/gobwas/glob" + + "github.com/osbuild/images/pkg/distro" +) + +func splitPrefixSearchTerm(s string) (string, string) { + l := strings.SplitN(s, ":", 2) + if len(l) == 1 { + return "", l[0] + } + return l[0], l[1] +} + +// newFilter creates an image filter based on the given filter terms. Glob like +// patterns (?, *) are supported, see fnmatch(3). +// +// Without a prefix in the filter term a simple name filtering is performed. +// With a prefix the specified property is filtered, e.g. "arch:i386". Adding +// filtering will narrow down the filtering (terms are combined via AND). +// +// The following prefixes are supported: +// "distro:" - the distro name, e.g. rhel-9, or fedora* +// "arch:" - the architecture, e.g. x86_64 +// "type": - the image type, e.g. ami, or qcow? +// "bootmode": - the bootmode, e.g. "legacy", "uefi", "hybrid" +func newFilter(sl ...string) (*filter, error) { + filter := &filter{ + terms: make([]term, len(sl)), + } + for i, s := range sl { + prefix, searchTerm := splitPrefixSearchTerm(s) + if !slices.Contains(supportedFilters, prefix) { + return nil, fmt.Errorf("unsupported filter prefix: %q", prefix) + } + gl, err := glob.Compile(searchTerm) + if err != nil { + return nil, err + } + filter.terms[i].prefix = prefix + filter.terms[i].pattern = gl + } + return filter, nil +} + +var supportedFilters = []string{ + "", "distro", "arch", "type", "bootmode", +} + +type term struct { + prefix string + pattern glob.Glob +} + +// filter provides a way to filter a list of image defintions for the +// given filter terms. +type filter struct { + terms []term +} + +// Matches returns true if the given (distro,arch,imgType) tuple matches +// the filter expressions +func (fl filter) Matches(distro distro.Distro, arch distro.Arch, imgType distro.ImageType) bool { + m := true + for _, term := range fl.terms { + switch term.prefix { + case "": + // no prefix, do a "fuzzy" search accross the common + // things users may want + m1 := term.pattern.Match(distro.Name()) + m2 := term.pattern.Match(arch.Name()) + m3 := term.pattern.Match(imgType.Name()) + m = m && (m1 || m2 || m3) + case "distro": + m = m && term.pattern.Match(distro.Name()) + case "arch": + m = m && term.pattern.Match(arch.Name()) + case "type": + m = m && term.pattern.Match(imgType.Name()) + // mostly here to show how flexible this is + case "bootmode": + m = m && term.pattern.Match(imgType.BootMode().String()) + } + } + return m +} diff --git a/pkg/imagefilter/filter_test.go b/pkg/imagefilter/filter_test.go new file mode 100644 index 0000000000..eb34528971 --- /dev/null +++ b/pkg/imagefilter/filter_test.go @@ -0,0 +1,63 @@ +package imagefilter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/osbuild/images/pkg/distrofactory" +) + +func TestImageFilterFilter(t *testing.T) { + fac := distrofactory.NewTestDefault() + + for _, tc := range []struct { + searchExpr []string + distro, arch, imgType string + expectsMatch bool + }{ + // no prefix is a "fuzzy" filter and will check distro/arch/imgType + {[]string{"foo"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"test-distro-1"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"test-distro*"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"test_arch3"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"qcow2"}, "test-distro-1", "test_arch3", "qcow2", true}, + // distro: prefix (exact matches only) + {[]string{"distro:bar"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"distro:test-distro-1"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"distro:test-distro"}, "test-distro-1", "test_arch3", "qcow2", false}, + // arch: prefix + {[]string{"arch:amd64"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"arch:test_arch3"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"arch:test_ar"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"arch:test_ar*"}, "test-distro-1", "test_arch3", "qcow2", true}, + // type: prefix + {[]string{"type:ami"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"type:qcow2"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"type:qcow"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"type:qcow?"}, "test-distro-1", "test_arch3", "qcow2", true}, + // bootmode: prefix + {[]string{"bootmode:uefi"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"bootmode:hybrid"}, "test-distro-1", "test_arch3", "qcow2", true}, + // multiple filters are AND + {[]string{"distro:test-distro-1", "type:ami"}, "test-distro-1", "test_arch3", "qcow2", false}, + {[]string{"distro:test-distro-1", "type:qcow2"}, "test-distro-1", "test_arch3", "qcow2", true}, + {[]string{"distro:test-distro-1", "arch:amd64", "type:qcow2"}, "test-distro-1", "test_arch3", "qcow2", false}, + } { + // XXX: it would be nice if TestDistro would support constructing + // like GetDistro("rhel-8.1:i386,amd64:ami,qcow2") instead of + // the current very static setup + di := fac.GetDistro(tc.distro) + require.NotNil(t, di) + ar, err := di.GetArch(tc.arch) + require.NoError(t, err) + im, err := ar.GetImageType(tc.imgType) + require.NoError(t, err) + ff, err := newFilter(tc.searchExpr...) + require.NoError(t, err) + + match := ff.Matches(di, ar, im) + assert.Equal(t, tc.expectsMatch, match, tc) + } +} diff --git a/pkg/imagefilter/imagefilter.go b/pkg/imagefilter/imagefilter.go new file mode 100644 index 0000000000..a217622f71 --- /dev/null +++ b/pkg/imagefilter/imagefilter.go @@ -0,0 +1,90 @@ +package imagefilter + +import ( + "fmt" + + "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/distrofactory" + "github.com/osbuild/images/pkg/distrosort" +) + +type DistroLister interface { + ListDistros() []string +} + +// Result contains a result from a imagefilter.Filter run +type Result struct { + Distro distro.Distro + Arch distro.Arch + ImgType distro.ImageType +} + +// ImageFilter is an a flexible way to filter the available images. +type ImageFilter struct { + fac *distrofactory.Factory + repos DistroLister +} + +// New creates a new ImageFilter that can be used to filter the list +// of available images +func New(fac *distrofactory.Factory, repos DistroLister) (*ImageFilter, error) { + if fac == nil { + return nil, fmt.Errorf("cannot create ImageFilter without a valid distrofactory") + } + if repos == nil { + return nil, fmt.Errorf("cannot create ImageFilter without a valid reporegistry") + } + + return &ImageFilter{fac: fac, repos: repos}, nil +} + +// Filter filters the available images for the given +// distrofactory/reporegistry based on the given filter terms. Glob +// like patterns (?, *) are supported, see fnmatch(3). +// +// Without a prefix in the filter term a simple name filtering is performed. +// With a prefix the specified property is filtered, e.g. "arch:i386". Adding +// filtering will narrow down the filtering (terms are combined via AND). +// +// The following prefixes are supported: +// "distro:" - the distro name, e.g. rhel-9, or fedora* +// "arch:" - the architecture, e.g. x86_64 +// "type": - the image type, e.g. ami, or qcow? +// "bootmode": - the bootmode, e.g. "legacy", "uefi", "hybrid" +func (i *ImageFilter) Filter(searchTerms ...string) ([]Result, error) { + var res []Result + + distroNames := i.repos.ListDistros() + filter, err := newFilter(searchTerms...) + if err != nil { + return nil, err + } + + if err := distrosort.Names(distroNames); err != nil { + return nil, err + } + for _, distroName := range distroNames { + distro := i.fac.GetDistro(distroName) + if distro == nil { + // XXX: log here? + continue + } + for _, archName := range distro.ListArches() { + a, err := distro.GetArch(archName) + if err != nil { + return nil, err + } + for _, imgTypeName := range a.ListImageTypes() { + imgType, err := a.GetImageType(imgTypeName) + if err != nil { + return nil, err + } + if filter.Matches(distro, a, imgType) { + res = append(res, Result{distro, a, imgType}) + } + } + } + } + + return res, nil +} diff --git a/pkg/imagefilter/imagefilter_test.go b/pkg/imagefilter/imagefilter_test.go new file mode 100644 index 0000000000..ab20ff56bf --- /dev/null +++ b/pkg/imagefilter/imagefilter_test.go @@ -0,0 +1,76 @@ +package imagefilter_test + +import ( + "testing" + + "github.com/sirupsen/logrus" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/osbuild/images/pkg/distrofactory" + "github.com/osbuild/images/pkg/imagefilter" + "github.com/osbuild/images/pkg/reporegistry" +) + +func TestImageFilterSmoke(t *testing.T) { + logrus.SetLevel(logrus.WarnLevel) + + fac := distrofactory.NewDefault() + repos, err := reporegistry.NewTestedDefault() + require.NoError(t, err) + + imgFilter, err := imagefilter.New(fac, repos) + require.NoError(t, err) + res, err := imgFilter.Filter("*") + require.NoError(t, err) + assert.True(t, len(res) > 0) +} + +func TestImageFilterFilter(t *testing.T) { + fac := distrofactory.NewDefault() + repos, err := reporegistry.NewTestedDefault() + require.NoError(t, err) + + imgFilter, err := imagefilter.New(fac, repos) + require.NoError(t, err) + + for _, tc := range []struct { + searchExpr []string + expectsMatch bool + }{ + // no prefix is a "fuzzy" filter and will check distro/arch/imgType + {[]string{"foo"}, false}, + {[]string{"rhel-9.1"}, true}, + {[]string{"rhel*"}, true}, + {[]string{"x86_64"}, true}, + {[]string{"qcow2"}, true}, + // distro: prefix + {[]string{"distro:foo"}, false}, + {[]string{"distro:centos-9"}, true}, + {[]string{"distro:centos*"}, true}, + {[]string{"distro:centos"}, false}, + // arch: prefix + {[]string{"arch:foo"}, false}, + {[]string{"arch:x86_64"}, true}, + {[]string{"arch:x86*"}, true}, + {[]string{"arch:x86"}, false}, + // type: prefix + {[]string{"type:foo"}, false}, + {[]string{"type:qcow2"}, true}, + {[]string{"type:qcow?"}, true}, + {[]string{"type:qcow"}, false}, + // bootmode: prefix + {[]string{"bootmode:foo"}, false}, + {[]string{"bootmode:hybrid"}, true}, + // multiple filters are AND + {[]string{"distro:centos-9", "type:foo"}, false}, + {[]string{"distro:centos-9", "type:qcow2"}, true}, + {[]string{"distro:centos-9", "arch:foo", "type:qcow2"}, false}, + } { + + matches, err := imgFilter.Filter(tc.searchExpr...) + assert.NoError(t, err) + assert.Equal(t, tc.expectsMatch, len(matches) > 0, tc) + } +}