Skip to content

Commit

Permalink
pkg: add new imagefilter package with an ImageFilter
Browse files Browse the repository at this point in the history
This commit creates a new `imagefilter` package that can be used
to create a filter for the available images that can be build by
the images library.

Support for prefixes and fnmatch style globbing is available.

This will eventually allows us to do:
```
$ image-builder --list-images --filter arch:aarch64 --filter type:qcow*
or
$ image-builder --list-images --filter ami
```

The filtering can be both with or without a prefix, i.e. when doing
a "simple" search all common fields (disro,arch,imageType) are checked
but with a prefix search things can be narrowed down more.

Initially the following prefixes filters 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"

(Split out from the spike in #997)
  • Loading branch information
mvo5 authored and thozza committed Nov 11, 2024
1 parent 5ed6d4e commit 9ca1cea
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 0 deletions.
92 changes: 92 additions & 0 deletions pkg/imagefilter/filter.go
Original file line number Diff line number Diff line change
@@ -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
}
63 changes: 63 additions & 0 deletions pkg/imagefilter/filter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions pkg/imagefilter/imagefilter.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions pkg/imagefilter/imagefilter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 9ca1cea

Please sign in to comment.