diff --git a/pkg/envtest/setup/list/list_test.go b/pkg/envtest/setup/list/list_test.go new file mode 100644 index 0000000000..10d2c1d4ba --- /dev/null +++ b/pkg/envtest/setup/list/list_test.go @@ -0,0 +1,226 @@ +package list_test + +import ( + "cmp" + "context" + "regexp" + "slices" + "testing" + + "github.com/go-logr/logr" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/list" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/testhelpers" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var ( + testLog logr.Logger + ctx context.Context +) + +func TestEnv(t *testing.T) { + testLog = testhelpers.GetLogger() + ctx = logr.NewContext(context.Background(), testLog) + + RegisterFailHandler(Fail) + RunSpecs(t, "List Suite") +} + +var _ = Describe("List", func() { + var ( + envOpts []env.Option + ) + + JustBeforeEach(func() { + addr, shutdown := testhelpers.NewServer() + DeferCleanup(shutdown) + + envOpts = append( + envOpts, + env.WithClient(&remote.Client{ + Log: testLog.WithName("test-remote-client"), + Bucket: "kubebuilder-tools-test", + Server: addr, + Insecure: true, + }), + env.WithStore(testhelpers.NewMockStore()), + ) + }) + + Context("when downloads are disabled", func() { + JustBeforeEach(func() { + envOpts = append(envOpts, env.WithClient(nil)) // ensure tests fail if we try to contact remote + }) + + It("should include local contents sorted by version", func() { + result, err := list.List( + ctx, + versions.AnyVersion, + list.NoDownload(true), + list.WithPlatform("*", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + // build this list based on test data, to avoid having to change + // in two places if we add some more test cases + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + for _, p := range v.Platforms { + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want the results sorted in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + }) + + Context("when downloads are enabled", func() { + It("should sort local & remote by version", func() { + result, err := list.List( + ctx, + versions.AnyVersion, + list.WithPlatform("*", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + // build this list based on test data, to avoid having to change + // in two places if we add some more test cases + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + for _, p := range v.Platforms { + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + rx := regexp.MustCompile(`^kubebuilder-tools-(\d+\.\d+\.\d+)-(\w+)-(\w+).tar.gz$`) + for _, v := range testhelpers.RemoteNames { + if m := rx.FindStringSubmatch(v); m != nil { + s, err := versions.FromExpr(m[1]) + Expect(err).NotTo(HaveOccurred()) + + expected = append(expected, list.Result{ + Version: *s.AsConcrete(), + Platform: versions.Platform{ + OS: m[2], + Arch: m[3], + }, + Status: list.Available, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want installed versions first, available after; + // compare in reverse order since "installed > "available" + cmp.Compare(b.Status, a.Status), + // then, sort in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + + It("should skip non-matching remote contents", func() { + result, err := list.List( + ctx, + versions.Spec{ + Selector: versions.PatchSelector{Major: 1, Minor: 16, Patch: versions.AnyPoint}, + }, + list.WithPlatform("*", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + // build this list based on test data, to avoid having to change + // in two places if we add some more test cases + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + // ignore versions not matching the filter in the options + if v.Version.Major != 1 || v.Version.Minor != 16 { + continue + } + for _, p := range v.Platforms { + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + rx := regexp.MustCompile(`^kubebuilder-tools-(\d+\.\d+\.\d+)-(\w+)-(\w+).tar.gz$`) + for _, v := range testhelpers.RemoteNames { + if m := rx.FindStringSubmatch(v); m != nil { + s, err := versions.FromExpr(m[1]) + Expect(err).NotTo(HaveOccurred()) + v := *s.AsConcrete() + // ignore versions not matching the filter in the options + if v.Major != 1 || v.Minor != 16 { + continue + } + expected = append(expected, list.Result{ + Version: v, + Platform: versions.Platform{ + OS: m[2], + Arch: m[3], + }, + Status: list.Available, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want installed versions first, available after; + // compare in reverse order since "installed > "available" + cmp.Compare(b.Status, a.Status), + // then, sort in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + }) +}) diff --git a/pkg/envtest/setup/testhelpers/logging.go b/pkg/envtest/setup/testhelpers/logging.go new file mode 100644 index 0000000000..9d2fdb15c0 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/logging.go @@ -0,0 +1,21 @@ +package testhelpers + +import ( + "github.com/go-logr/logr" + "github.com/go-logr/zapr" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/onsi/ginkgo/v2" +) + +// GetLogger configures a logger that's suitable for testing +func GetLogger() logr.Logger { + testOut := zapcore.AddSync(ginkgo.GinkgoWriter) + enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) + + zapLog := zap.New(zapcore.NewCore(enc, testOut, zap.DebugLevel), + zap.ErrorOutput(testOut), zap.Development(), zap.AddStacktrace(zap.WarnLevel)) + + return zapr.NewLogger(zapLog) +} diff --git a/pkg/envtest/setup/testhelpers/remote.go b/pkg/envtest/setup/testhelpers/remote.go new file mode 100644 index 0000000000..50ba238069 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/remote.go @@ -0,0 +1,159 @@ +package testhelpers + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/md5" //nolint:gosec + "crypto/rand" + "encoding/base64" + "net/http" + + "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" +) + +// objectList is the parts we need of the GCS "list-objects-in-bucket" endpoint. +type objectList struct { + Items []bucketObject `json:"items"` +} + +// bucketObject is the parts we need of the GCS object metadata. +type bucketObject struct { + Name string `json:"name"` + Hash string `json:"md5Hash"` +} + +type item struct { + meta bucketObject + contents []byte +} + +var ( + // RemoteNames are all the package names that can be used on the mock server. + // Provide this, or a subset of it, to NewServerWithContents to get a mock server that knows about those packages. + RemoteNames = []string{ + "kubebuilder-tools-1.10-darwin-amd64.tar.gz", + "kubebuilder-tools-1.10-linux-amd64.tar.gz", + "kubebuilder-tools-1.10.1-darwin-amd64.tar.gz", + "kubebuilder-tools-1.10.1-linux-amd64.tar.gz", + "kubebuilder-tools-1.11.0-darwin-amd64.tar.gz", + "kubebuilder-tools-1.11.0-linux-amd64.tar.gz", + "kubebuilder-tools-1.11.1-potato-cherrypie.tar.gz", + "kubebuilder-tools-1.12.3-darwin-amd64.tar.gz", + "kubebuilder-tools-1.12.3-linux-amd64.tar.gz", + "kubebuilder-tools-1.13.1-darwin-amd64.tar.gz", + "kubebuilder-tools-1.13.1-linux-amd64.tar.gz", + "kubebuilder-tools-1.14.1-darwin-amd64.tar.gz", + "kubebuilder-tools-1.14.1-linux-amd64.tar.gz", + "kubebuilder-tools-1.15.5-darwin-amd64.tar.gz", + "kubebuilder-tools-1.15.5-linux-amd64.tar.gz", + "kubebuilder-tools-1.16.4-darwin-amd64.tar.gz", + "kubebuilder-tools-1.16.4-linux-amd64.tar.gz", + "kubebuilder-tools-1.17.9-darwin-amd64.tar.gz", + "kubebuilder-tools-1.17.9-linux-amd64.tar.gz", + "kubebuilder-tools-1.19.0-darwin-amd64.tar.gz", + "kubebuilder-tools-1.19.0-linux-amd64.tar.gz", + "kubebuilder-tools-1.19.2-darwin-amd64.tar.gz", + "kubebuilder-tools-1.19.2-linux-amd64.tar.gz", + "kubebuilder-tools-1.19.2-linux-arm64.tar.gz", + "kubebuilder-tools-1.19.2-linux-ppc64le.tar.gz", + "kubebuilder-tools-1.20.2-darwin-amd64.tar.gz", + "kubebuilder-tools-1.20.2-linux-amd64.tar.gz", + "kubebuilder-tools-1.20.2-linux-arm64.tar.gz", + "kubebuilder-tools-1.20.2-linux-ppc64le.tar.gz", + "kubebuilder-tools-1.9-darwin-amd64.tar.gz", + "kubebuilder-tools-1.9-linux-amd64.tar.gz", + "kubebuilder-tools-v1.19.2-darwin-amd64.tar.gz", + "kubebuilder-tools-v1.19.2-linux-amd64.tar.gz", + "kubebuilder-tools-v1.19.2-linux-arm64.tar.gz", + "kubebuilder-tools-v1.19.2-linux-ppc64le.tar.gz", + } + + contents map[string]item +) + +func makeContents(names []string) []item { + res := make([]item, len(names)) + if contents == nil { + contents = make(map[string]item, len(RemoteNames)) + } + + for i, name := range names { + if item, ok := contents[name]; ok { + res[i] = item + continue + } + + var chunk [1024 * 48]byte // 1.5 times our chunk read size in GetVersion + copy(chunk[:], name) + if _, err := rand.Read(chunk[len(name):]); err != nil { + panic(err) + } + item := verWith(name, chunk[:]) + contents[name] = item + res[i] = item + } + return res +} +func verWith(name string, contents []byte) item { + out := new(bytes.Buffer) + gzipWriter := gzip.NewWriter(out) + tarWriter := tar.NewWriter(gzipWriter) + err := tarWriter.WriteHeader(&tar.Header{ + Name: "kubebuilder/bin/some-file", + Size: int64(len(contents)), + Mode: 0777, // so we can check that we fix this later + }) + if err != nil { + panic(err) + } + _, err = tarWriter.Write(contents) + if err != nil { + panic(err) + } + tarWriter.Close() + gzipWriter.Close() + res := item{ + meta: bucketObject{Name: name}, + contents: out.Bytes(), + } + hash := md5.Sum(res.contents) //nolint:gosec + res.meta.Hash = base64.StdEncoding.EncodeToString(hash[:]) + return res +} + +func configure(versions []item) (string, func()) { + server := ghttp.NewServer() + + list := objectList{Items: make([]bucketObject, len(versions))} + for i, ver := range versions { + ver := ver // copy to avoid capturing the iteration variable + list.Items[i] = ver.meta + server.RouteToHandler("GET", "/storage/v1/b/kubebuilder-tools-test/o/"+ver.meta.Name, func(resp http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("alt") == "media" { + resp.WriteHeader(http.StatusOK) + gomega.Expect(resp.Write(ver.contents)).To(gomega.Equal(len(ver.contents))) + } else { + ghttp.RespondWithJSONEncoded( + http.StatusOK, + ver.meta, + )(resp, req) + } + }) + } + server.RouteToHandler("GET", "/storage/v1/b/kubebuilder-tools-test/o", ghttp.RespondWithJSONEncoded( + http.StatusOK, + list, + )) + + return server.Addr(), server.Close +} + +// NewServer spins up a mock server that knows about the provided packages. +// The package names should be a subset of RemoteNames. +// +// The returned shutdown function should be called at the end of the test +func NewServer() (addr string, shutdown func()) { + return configure(makeContents(RemoteNames)) +} diff --git a/pkg/envtest/setup/testhelpers/store.go b/pkg/envtest/setup/testhelpers/store.go new file mode 100644 index 0000000000..e53355b087 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/store.go @@ -0,0 +1,72 @@ +package testhelpers + +import ( + "path/filepath" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/spf13/afero" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var ( + // keep this sorted. + + LocalVersions = []versions.Set{ + {Version: versions.Concrete{Major: 1, Minor: 17, Patch: 9}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 2}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "yourimagination"}}, + {Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 1}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 14, Patch: 26}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Platform: versions.Platform{OS: "hyperwarp", Arch: "pixiedust"}}, + }}, + } +) + +func initializeFakeStore(fs afero.Afero, dir string) { + ginkgo.By("making the unpacked directory") + unpackedBase := filepath.Join(dir, "k8s") + gomega.Expect(fs.Mkdir(unpackedBase, 0755)).To(gomega.Succeed()) + + ginkgo.By("making some fake (empty) versions") + for _, set := range LocalVersions { + for _, plat := range set.Platforms { + gomega.Expect(fs.Mkdir(filepath.Join(unpackedBase, plat.BaseName(set.Version)), 0755)).To(gomega.Succeed()) + } + } + + ginkgo.By("making some fake non-store paths") + gomega.Expect(fs.Mkdir(filepath.Join(dir, "missing-binaries"), 0755)).To(gomega.Succeed()) + + gomega.Expect(fs.Mkdir(filepath.Join(dir, "wrong-version"), 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong-version", "kube-apiserver"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong-version", "kubectl"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong-version", "etcd"), nil, 0755)).To(gomega.Succeed()) + + gomega.Expect(fs.Mkdir(filepath.Join(dir, "good-version"), 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "good-version", "kube-apiserver"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "good-version", "kubectl"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "good-version", "etcd"), nil, 0755)).To(gomega.Succeed()) + // TODO: put the right files +} + +// NewMockStore creates a new in-memory store, prepopulated with a set of packages +func NewMockStore() *store.Store { + fs := afero.NewMemMapFs() + storeRoot := ".test-binaries" + + initializeFakeStore(afero.Afero{Fs: fs}, storeRoot) + + return &store.Store{Root: afero.NewBasePathFs(fs, storeRoot)} +}