Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pkg: add new imagefilter package with an ImageFilter #1015

Merged
merged 1 commit into from
Nov 11, 2024
Merged
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
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"
mvo5 marked this conversation as resolved.
Show resolved Hide resolved
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
mvo5 marked this conversation as resolved.
Show resolved Hide resolved
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)
}
}
Loading