diff --git a/examples/scratch-env/go.mod b/examples/scratch-env/go.mod index f955bd34d0..79899dbf0c 100644 --- a/examples/scratch-env/go.mod +++ b/examples/scratch-env/go.mod @@ -26,7 +26,7 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.3.1 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -46,7 +46,7 @@ require ( golang.org/x/sys v0.19.0 // indirect golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/time v0.3.0 // indirect + golang.org/x/time v0.5.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/examples/scratch-env/go.sum b/examples/scratch-env/go.sum index b90a2d8422..3bcc1ff425 100644 --- a/examples/scratch-env/go.sum +++ b/examples/scratch-env/go.sum @@ -43,8 +43,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -137,8 +137,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/go.mod b/go.mod index 0d2ca57f0b..81b6eeba63 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,10 @@ require ( sigs.k8s.io/yaml v1.3.0 ) -require golang.org/x/mod v0.15.0 +require ( + github.com/spf13/afero v1.11.0 + golang.org/x/mod v0.15.0 +) require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -53,7 +56,7 @@ require ( github.com/google/cel-go v0.20.1 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect - github.com/google/uuid v1.3.1 // indirect + github.com/google/uuid v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -81,11 +84,11 @@ require ( golang.org/x/sync v0.6.0 // indirect golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/time v0.3.0 // indirect + golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.18.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 58b291e93a..759ef16d7f 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -110,6 +110,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= @@ -185,8 +187,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -201,12 +203,12 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY= -google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= diff --git a/pkg/envtest/setup/cleanup/cleanup.go b/pkg/envtest/setup/cleanup/cleanup.go new file mode 100644 index 0000000000..9aecede13d --- /dev/null +++ b/pkg/envtest/setup/cleanup/cleanup.go @@ -0,0 +1,41 @@ +package cleanup + +import ( + "context" + "errors" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// Result is a list of version-platform pairs that were removed from the store. +type Result []store.Item + +// Cleanup removes binary packages from disk for all version-platform pairs that match the parameters +// +// Note that both the item collection and the error might be non-nil, if some packages were successfully +// removed (they will be listed in the first return value) and some failed (the errors will be collected +// in the second). +func Cleanup(ctx context.Context, spec versions.Spec, options ...Option) (Result, error) { + cfg := configure(options...) + + env, err := env.New(cfg.envOpts...) + if err != nil { + return nil, err + } + + if err := env.Store.Initialize(ctx); err != nil { + return nil, err + } + + items, err := env.Store.Remove(ctx, store.Filter{Version: spec, Platform: cfg.platform}) + if errors.Is(err, store.ErrUnableToList) { + return nil, err + } + + // store.Remove returns an error if _any_ item failed to be removed, + // but it also reports any items that were removed without errors. + // Therefore, both items and err might be non-nil at the same time. + return items, err +} diff --git a/pkg/envtest/setup/cleanup/cleanup_test.go b/pkg/envtest/setup/cleanup/cleanup_test.go new file mode 100644 index 0000000000..6c8c6c600e --- /dev/null +++ b/pkg/envtest/setup/cleanup/cleanup_test.go @@ -0,0 +1,97 @@ +package cleanup_test + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/cleanup" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "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 TestCleanup(t *testing.T) { + testLog = testhelpers.GetLogger() + ctx = logr.NewContext(context.Background(), testLog) + + RegisterFailHandler(Fail) + RunSpecs(t, "Cleanup Suite") +} + +var _ = Describe("Cleanup", func() { + var ( + defaultEnvOpts []env.Option + s *store.Store + ) + + BeforeEach(func() { + s = testhelpers.NewMockStore() + }) + + JustBeforeEach(func() { + defaultEnvOpts = []env.Option{ + env.WithClient(nil), // ensures we fail if we try to connect + env.WithStore(s), + env.WithFS(afero.NewIOFS(s.Root)), + } + }) + + Context("when cleanup is run", func() { + version := versions.Spec{ + Selector: versions.Concrete{ + Major: 1, + Minor: 16, + Patch: 1, + }, + } + + var ( + matching, nonMatching []store.Item + ) + + BeforeEach(func() { + // ensure there are some versions matching what we're about to delete + var err error + matching, err = s.List(ctx, store.Filter{Version: version, Platform: versions.Platform{OS: "linux", Arch: "amd64"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(matching).NotTo(BeEmpty(), "found no matching versions before cleanup") + + // ensure there are some versions _not_ matching what we're about to delete + nonMatching, err = s.List(ctx, store.Filter{Version: versions.Spec{Selector: versions.PatchSelector{Major: 1, Minor: 17, Patch: versions.AnyPoint}}, Platform: versions.Platform{OS: "linux", Arch: "amd64"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(nonMatching).NotTo(BeEmpty(), "found no non-matching versions before cleanup") + }) + + JustBeforeEach(func() { + Expect(cleanup.Cleanup( + ctx, + version, + cleanup.WithPlatform("linux", "amd64"), + cleanup.WithEnvOptions(defaultEnvOpts...), + )).To(Succeed()) + }) + + It("should remove matching versions", func() { + items, err := s.List(ctx, store.Filter{Version: version, Platform: versions.Platform{OS: "linux", Arch: "amd64"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(items).To(BeEmpty(), "found matching versions after cleanup") + }) + + It("should not remove non-matching versions", func() { + items, err := s.List(ctx, store.Filter{Version: versions.AnyVersion, Platform: versions.Platform{OS: "*", Arch: "*"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(items).To(ContainElements(nonMatching), "non-matching items were affected") + }) + }) +}) diff --git a/pkg/envtest/setup/cleanup/config.go b/pkg/envtest/setup/cleanup/config.go new file mode 100644 index 0000000000..696b90da72 --- /dev/null +++ b/pkg/envtest/setup/cleanup/config.go @@ -0,0 +1,45 @@ +package cleanup + +import ( + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + envOpts []env.Option + platform versions.Platform +} + +// Option is a functional option for configuring the cleanup process. +type Option func(*config) + +// WithEnvOptions adds options to the environment setup. +func WithEnvOptions(opts ...env.Option) Option { + return func(cfg *config) { + cfg.envOpts = append(cfg.envOpts, opts...) + } +} + +// WithPlatform sets the platform to use for cleanup. +func WithPlatform(os string, arch string) Option { + return func(cfg *config) { + cfg.platform = versions.Platform{OS: os, Arch: arch} + } +} + +func configure(options ...Option) *config { + cfg := &config{ + platform: versions.Platform{ + Arch: runtime.GOARCH, + OS: runtime.GOOS, + }, + } + + for _, opt := range options { + opt(cfg) + } + + return cfg +} diff --git a/pkg/envtest/setup/env/assets.go b/pkg/envtest/setup/env/assets.go new file mode 100644 index 0000000000..e1754f7322 --- /dev/null +++ b/pkg/envtest/setup/env/assets.go @@ -0,0 +1,69 @@ +package env + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path/filepath" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var expectedExecutables = []string{ + "kube-apiserver", + "etcd", + "kubectl", +} + +// TryUseAssetsFromPath attempts to use the assets from the provided path if they match the spec. +// If they do not, or if some executable is missing, it returns an empty string. +func (e *Env) TryUseAssetsFromPath(ctx context.Context, spec versions.Spec, path string) (versions.Spec, bool) { + v, err := versions.FromPath(path) + if err != nil { + ok, checkErr := e.hasAllExecutables(path) + log.FromContext(ctx).Info("has all executables", "ok", ok, "err", checkErr) + if checkErr != nil { + log.FromContext(ctx).Error(errors.Join(err, checkErr), "Failed checking if assets path has all binaries, ignoring", "path", path) + return versions.Spec{}, false + } else if ok { + // If the path has all executables, we can use it even if we can't parse the version. + // The user explicitly asked for this path, so set the version to a wildcard so that + // it passes checks downstream. + return versions.AnyVersion, true + } + + log.FromContext(ctx).Error(errors.Join(err, errors.New("some required binaries missing")), "Unable to use assets from path, ignoring", "path", path) + return versions.Spec{}, false + } + + if !spec.Matches(*v) { + log.FromContext(ctx).Error(nil, "Assets path does not match spec, ignoring", "path", path, "spec", spec) + return versions.Spec{}, false + } + + if ok, err := e.hasAllExecutables(path); err != nil { + log.FromContext(ctx).Error(err, "Failed checking if assets path has all binaries, ignoring", "path", path) + return versions.Spec{}, false + } else if !ok { + log.FromContext(ctx).Error(nil, "Assets path is missing some executables, ignoring", "path", path) + return versions.Spec{}, false + } + + return versions.Spec{Selector: v}, true +} + +func (e *Env) hasAllExecutables(path string) (bool, error) { + for _, expected := range expectedExecutables { + _, err := e.FS.Open(filepath.Join(path, expected)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("check for existence of %s binary in %s: %w", expected, path, err) + } + } + + return true, nil +} diff --git a/pkg/envtest/setup/env/env.go b/pkg/envtest/setup/env/env.go new file mode 100644 index 0000000000..225d1b2df4 --- /dev/null +++ b/pkg/envtest/setup/env/env.go @@ -0,0 +1,72 @@ +package env + +import ( + "io/fs" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" +) + +// KubebuilderAssetsEnvVar is the environment variable that can be used to override the default local storage location +const KubebuilderAssetsEnvVar = "KUBEBUILDER_ASSETS" + +// Env encapsulates the environment dependencies. +type Env struct { + *store.Store + remote.Client + fs.FS +} + +// Option is a functional option for configuring an environment +type Option func(*Env) + +// WithStoreAt sets the path to the store directory. +func WithStoreAt(dir string) Option { + return func(c *Env) { c.Store = store.NewAt(dir) } +} + +// WithStore allows injecting a envured store. +func WithStore(store *store.Store) Option { + return func(c *Env) { c.Store = store } +} + +// WithClient allows injecting a envured remote client. +func WithClient(client remote.Client) Option { return func(c *Env) { c.Client = client } } + +// WithFS allows injecting a configured fs.FS, e.g. for mocking. +// TODO: fix this so it's actually used! +func WithFS(fs fs.FS) Option { + return func(c *Env) { + c.FS = fs + } +} + +// New returns a new environment, configured with the provided options. +// +// If no options are provided, it will be created with a production store.Store and remote.Client +// and an OS file system. +func New(options ...Option) (*Env, error) { + env := &Env{ + // this is the minimal configuration that won't panic + Client: &remote.GCSClient{ //nolint:staticcheck + Bucket: remote.DefaultBucket, //nolint:staticcheck + Server: remote.DefaultServer, //nolint:staticcheck + Log: logr.Discard(), + }, + } + + for _, option := range options { + option(env) + } + + if env.Store == nil { + dir, err := store.DefaultStoreDir() + if err != nil { + return nil, err + } + env.Store = store.NewAt(dir) + } + + return env, nil +} diff --git a/pkg/envtest/setup/env/local.go b/pkg/envtest/setup/env/local.go new file mode 100644 index 0000000000..fd50e2933f --- /dev/null +++ b/pkg/envtest/setup/env/local.go @@ -0,0 +1,36 @@ +package env + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// SelectLocalVersion returns the latest local version that matches the provided spec and platform, +// or nil if no such version is available. +// +// Note that a nil error does not guarantee that a version was found! +func (e *Env) SelectLocalVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (store.Item, error) { + localVersions, err := e.Store.List(ctx, store.Filter{Version: spec, Platform: platform}) + if err != nil { + return store.Item{}, err + } + // NB(tomasaschan): this assumes the following of the contract for store.List + // * only results matching the provided filter are returned + // * they are returned sorted, with the newest version first in the list + // Within these constraints, if the slice is non-empty, the first item is the one, + // we want, and there's no need to iterate through the items again. + if len(localVersions) > 0 { + // copy to avoid holding on to the entire slice + result := localVersions[0] + return result, nil + } + + return store.Item{}, nil +} + +// PathTo returns the local path to the assets directory for the provided version and platform +func (e *Env) PathTo(version *versions.Concrete, platform versions.Platform) string { + return e.Store.Path(store.Item{Version: *version, Platform: platform}) +} diff --git a/pkg/envtest/setup/env/remote.go b/pkg/envtest/setup/env/remote.go new file mode 100644 index 0000000000..826d50f88e --- /dev/null +++ b/pkg/envtest/setup/env/remote.go @@ -0,0 +1,98 @@ +package env + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// SelectRemoteVersion finds the latest remote version that matches the provided spec and platform. +func (e *Env) SelectRemoteVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (*versions.Concrete, versions.PlatformItem, error) { + vs, err := e.Client.ListVersions(ctx) + if err != nil { + return nil, versions.PlatformItem{}, err + } + + for _, v := range vs { + if !spec.Matches(v.Version) { + log.FromContext(ctx).V(1).Info("skipping non-matching version", "version", v.Version) + continue + } + + for _, p := range v.Platforms { + if platform.Matches(p.Platform) { + // copy to avoid holding on to the entire slice + ver := v.Version + return &ver, p, nil + } + } + + plats := make([]versions.Platform, 0) + for _, p := range v.Platforms { + plats = append(plats, p.Platform) + } + + log.FromContext(ctx).Info("version not available for your platform; skipping", "version", v.Version, "platforms", plats) + } + + return nil, versions.PlatformItem{}, fmt.Errorf("no applicable packages found for version %s and platform %s", spec, platform) +} + +// FetchRemoteVersion downloads the specified version and platform binaries and extracts them to the appropriate path +// +// If verifySum is true, it will also fetch the md5 sum for the version and platform and check the hashsum of the downloaded archive. +func (e *Env) FetchRemoteVersion(ctx context.Context, version *versions.Concrete, platform versions.PlatformItem, verifySum bool) error { + if verifySum && platform.Hash == nil { + hash, err := e.FetchSum(ctx, *version, platform.Platform) + if err != nil { + return fmt.Errorf("fetch md5 sum for version %s, platform %s: %w", version, platform.Platform, err) + } + + platform.Hash = hash + } else if !verifySum { + // turn off checksum verification + platform.Hash = nil + } + + _, useGCS := e.Client.(*remote.GCSClient) //nolint:staticcheck + archiveOut, err := os.CreateTemp("", "*-"+platform.ArchiveName(useGCS, *version)) + if err != nil { + return fmt.Errorf("open temporary download location: %w", err) + } + // cleanup defer needs to be the first one defined, so it's the last one to run + packedPath := "" + defer func() { + if packedPath != "" { + if err := os.Remove(packedPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + log.FromContext(ctx).V(1).Error(err, "Unable to clean up %s", packedPath) + } + } + }() + defer archiveOut.Close() + + packedPath = archiveOut.Name() + if err := e.Client.GetVersion(ctx, *version, platform, archiveOut); err != nil { + return fmt.Errorf("download archive: %w", err) + } + + if err := archiveOut.Sync(); err != nil { + return fmt.Errorf("flush downloaded file: %w", err) + } + + if _, err := archiveOut.Seek(0, 0); err != nil { + return fmt.Errorf("jump to start of archive: %w", err) + } + + if err := e.Store.Add(ctx, store.Item{Version: *version, Platform: platform.Platform}, archiveOut); err != nil { + return fmt.Errorf("store version to disk: %w", err) + } + + return nil +} diff --git a/pkg/envtest/setup/list/config.go b/pkg/envtest/setup/list/config.go new file mode 100644 index 0000000000..dfc6fa561e --- /dev/null +++ b/pkg/envtest/setup/list/config.go @@ -0,0 +1,46 @@ +package list + +import ( + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + platform versions.Platform + localOnly bool + envOpts []env.Option +} + +// Option is a functional option for configuring the list workflow +type Option func(*config) + +// WithEnvOptions provides options for the env.Env used by the workflow +func WithEnvOptions(opts ...env.Option) Option { + return func(c *config) { c.envOpts = append(c.envOpts, opts...) } +} + +// WithPlatform sets the target OS and architecture for the download. +func WithPlatform(os string, arch string) Option { + return func(c *config) { c.platform = versions.Platform{OS: os, Arch: arch} } +} + +// NoDownload ensures only local versions are considered +func NoDownload(noDownload bool) Option { return func(c *config) { c.localOnly = noDownload } } + +func configure(options ...Option) *config { + cfg := &config{} + + for _, opt := range options { + opt(cfg) + } + + if cfg.platform == (versions.Platform{}) { + cfg.platform = versions.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + } + return cfg +} diff --git a/pkg/envtest/setup/list/list.go b/pkg/envtest/setup/list/list.go new file mode 100644 index 0000000000..16df8951b4 --- /dev/null +++ b/pkg/envtest/setup/list/list.go @@ -0,0 +1,81 @@ +package list + +import ( + "cmp" + "context" + "fmt" + "slices" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// Status indicates whether a version is available locally or remotely +type Status string + +const ( + // Installed indicates that this version is installed on the local system + Installed Status = "installed" + // Available indicates that this version is available to download + Available Status = "available" +) + +// Result encapsulates a single item in the list of versions +type Result struct { + Version versions.Concrete + Platform versions.Platform + Status Status +} + +// List lists available versions, local and remote +func List(ctx context.Context, version versions.Spec, options ...Option) ([]Result, error) { + cfg := configure(options...) + env, err := env.New(cfg.envOpts...) + if err != nil { + return nil, err + } + + if err := env.Store.Initialize(ctx); err != nil { + return nil, err + } + + vs, err := env.Store.List(ctx, store.Filter{Version: version, Platform: cfg.platform}) + if err != nil { + return nil, fmt.Errorf("list installed versions: %w", err) + } + + results := make([]Result, 0, len(vs)) + for _, v := range vs { + results = append(results, Result{Version: v.Version, Platform: v.Platform, Status: Installed}) + } + + if cfg.localOnly { + return results, nil + } + + remoteVersions, err := env.Client.ListVersions(ctx) + if err != nil { + return nil, fmt.Errorf("list available versions: %w", err) + } + + for _, set := range remoteVersions { + if !version.Matches(set.Version) { + continue + } + slices.SortFunc(set.Platforms, func(a, b versions.PlatformItem) int { + return cmp.Or(cmp.Compare(a.OS, b.OS), cmp.Compare(a.Arch, b.Arch)) + }) + for _, plat := range set.Platforms { + if cfg.platform.Matches(plat.Platform) { + results = append(results, Result{ + Version: set.Version, + Platform: plat.Platform, + Status: Available, + }) + } + } + } + + return results, nil +} diff --git a/pkg/envtest/setup/list/list_test.go b/pkg/envtest/setup/list/list_test.go new file mode 100644 index 0000000000..9ab8eb84f0 --- /dev/null +++ b/pkg/envtest/setup/list/list_test.go @@ -0,0 +1,274 @@ +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, err := testhelpers.NewServer() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(shutdown) + + envOpts = append( + envOpts, + env.WithClient(&remote.GCSClient{ //nolint:staticcheck + 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)) + }) + + It("should skip non-matching local contents", func() { + spec := versions.Spec{ + Selector: versions.PatchSelector{Major: 1, Minor: 16, Patch: versions.AnyPoint}, + } + result, err := list.List( + ctx, + spec, + list.NoDownload(true), + list.WithPlatform("linux", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + if !spec.Matches(v.Version) { + continue + } + for _, p := range v.Platforms { + if p.OS != "linux" { + continue + } + + 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/remote/client.go b/pkg/envtest/setup/remote/client.go new file mode 100644 index 0000000000..3084d50d2b --- /dev/null +++ b/pkg/envtest/setup/remote/client.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 The Kubernetes Authors + +package remote + +import ( + "context" + "errors" + "io" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// ErrChecksumMismatch is returned when the checksum of the downloaded archive does not match the expected checksum. +var ErrChecksumMismatch = errors.New("checksum mismatch") + +// Client is an interface to get and list envtest binary archives. +type Client interface { + ListVersions(ctx context.Context) ([]versions.Set, error) + + GetVersion(ctx context.Context, version versions.Concrete, platform versions.PlatformItem, out io.Writer) error + + FetchSum(ctx context.Context, ver versions.Concrete, pl versions.Platform) (*versions.Hash, error) + + LatestVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (versions.Concrete, error) +} diff --git a/tools/setup-envtest/remote/gcs_client.go b/pkg/envtest/setup/remote/gcs_client.go similarity index 74% rename from tools/setup-envtest/remote/gcs_client.go rename to pkg/envtest/setup/remote/gcs_client.go index 85f321d5c5..4eda7f0498 100644 --- a/tools/setup-envtest/remote/gcs_client.go +++ b/pkg/envtest/setup/remote/gcs_client.go @@ -14,7 +14,7 @@ import ( "sort" "github.com/go-logr/logr" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) // objectList is the parts we need of the GCS "list-objects-in-bucket" endpoint. @@ -31,6 +31,18 @@ type bucketObject struct { var _ Client = &GCSClient{} +const ( + // DefaultBucket is the default GCS bucket to fetch from, if using the old and deprecated GCS source + // + // Deprecated: Please use the HTTP client and its default options instead + DefaultBucket = "kubebuilder-tools" + + // DefaultServer is the default GCS-like storage server to fetch from, if using the old and deprecated GCS source + // + // Deprecated: Please use the HTTP client and its default options instead + DefaultServer = "storage.googleapis.com" +) + // GCSClient is a basic client for fetching versions of the envtest binary archives // from GCS. // @@ -166,7 +178,7 @@ func (c *GCSClient) GetVersion(ctx context.Context, version versions.Concrete, p // FetchSum fetches the checksum for the given concrete version & platform into // the given platform item. -func (c *GCSClient) FetchSum(ctx context.Context, ver versions.Concrete, pl *versions.PlatformItem) error { +func (c *GCSClient) FetchSum(ctx context.Context, ver versions.Concrete, pl versions.Platform) (*versions.Hash, error) { itemName := pl.ArchiveName(true, ver) loc := &url.URL{ Scheme: c.scheme(), @@ -176,27 +188,51 @@ func (c *GCSClient) FetchSum(ctx context.Context, ver versions.Concrete, pl *ver req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil) if err != nil { - return fmt.Errorf("unable to construct request to fetch metadata for %s: %w", itemName, err) + return nil, fmt.Errorf("unable to construct request to fetch metadata for %s: %w", itemName, err) } resp, err := http.DefaultClient.Do(req) if err != nil { - return fmt.Errorf("unable to fetch metadata for %s: %w", itemName, err) + return nil, fmt.Errorf("unable to fetch metadata for %s: %w", itemName, err) } defer resp.Body.Close() if resp.StatusCode != 200 { - return fmt.Errorf("unable fetch metadata for %s -- got status %q from GCS", itemName, resp.Status) + return nil, fmt.Errorf("unable fetch metadata for %s -- got status %q from GCS", itemName, resp.Status) } var item bucketObject if err := json.NewDecoder(resp.Body).Decode(&item); err != nil { - return fmt.Errorf("unable to unmarshal metadata for %s: %w", itemName, err) + return nil, fmt.Errorf("unable to unmarshal metadata for %s: %w", itemName, err) } - pl.Hash = &versions.Hash{ + return &versions.Hash{ Type: versions.MD5HashType, Encoding: versions.Base64HashEncoding, Value: item.Hash, + }, nil +} + +// LatestVersion returns the latest version of the tools that matches the given spec and platform. +func (c *GCSClient) LatestVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (versions.Concrete, error) { + vers, err := c.ListVersions(ctx) + if err != nil { + return versions.Concrete{}, fmt.Errorf("unable to list versions: %w", err) + } + + for _, set := range vers { + if !spec.Matches(set.Version) { + c.Log.V(1).Info("Skipping non-matching version", "version", set.Version) + continue + } + + for _, plat := range set.Platforms { + if platform.Matches(plat.Platform) { + return set.Version, nil + } + } + + c.Log.V(1).Info("Version is not supported on your platform, checking older ones", "version", set.Version, "platform", platform) } - return nil + + return versions.Concrete{}, fmt.Errorf("no version found for platform %s", platform) } diff --git a/tools/setup-envtest/remote/http_client.go b/pkg/envtest/setup/remote/http_client.go similarity index 84% rename from tools/setup-envtest/remote/http_client.go rename to pkg/envtest/setup/remote/http_client.go index 0339654a82..8ab265b303 100644 --- a/tools/setup-envtest/remote/http_client.go +++ b/pkg/envtest/setup/remote/http_client.go @@ -12,7 +12,7 @@ import ( "sort" "github.com/go-logr/logr" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" "sigs.k8s.io/yaml" ) @@ -143,12 +143,11 @@ func (c *HTTPClient) GetVersion(ctx context.Context, version versions.Concrete, return readBody(resp, out, name, platform) } -// FetchSum fetches the checksum for the given concrete version & platform into -// the given platform item. -func (c *HTTPClient) FetchSum(ctx context.Context, version versions.Concrete, platform *versions.PlatformItem) error { +// FetchSum fetches the checksum for the given concrete version & platform +func (c *HTTPClient) FetchSum(ctx context.Context, version versions.Concrete, platform versions.Platform) (*versions.Hash, error) { index, err := c.getIndex(ctx) if err != nil { - return err + return nil, err } for _, releases := range index.Releases { @@ -160,17 +159,41 @@ func (c *HTTPClient) FetchSum(ctx context.Context, version versions.Concrete, pl } if *ver == version && details.OS == platform.OS && details.Arch == platform.Arch { - platform.Hash = &versions.Hash{ + return &versions.Hash{ Type: versions.SHA512HashType, Encoding: versions.HexHashEncoding, Value: archive.Hash, - } - return nil + }, nil } } } - return fmt.Errorf("unable to find archive for %s (%s,%s)", version, platform.OS, platform.Arch) + return nil, fmt.Errorf("unable to find archive for %s (%s,%s)", version, platform.OS, platform.Arch) +} + +// LatestVersion returns the latest version that matches the given spec and platform. +func (c *HTTPClient) LatestVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (versions.Concrete, error) { + vers, err := c.ListVersions(ctx) + if err != nil { + return versions.Concrete{}, fmt.Errorf("unable to list versions: %w", err) + } + + for _, set := range vers { + if !spec.Matches(set.Version) { + c.Log.V(1).Info("Skipping non-matching version", "version", set.Version) + continue + } + + for _, plat := range set.Platforms { + if platform.Matches(plat.Platform) { + return set.Version, nil + } + } + + c.Log.V(1).Info("Version is not supported on your platform, checking older ones", "version", set.Version, "platform", platform) + } + + return versions.Concrete{}, fmt.Errorf("no version found for platform %s", platform) } func (c *HTTPClient) getIndex(ctx context.Context) (*Index, error) { diff --git a/tools/setup-envtest/remote/read_body.go b/pkg/envtest/setup/remote/read_body.go similarity index 91% rename from tools/setup-envtest/remote/read_body.go rename to pkg/envtest/setup/remote/read_body.go index 650e41282c..77946f9b18 100644 --- a/tools/setup-envtest/remote/read_body.go +++ b/pkg/envtest/setup/remote/read_body.go @@ -15,7 +15,7 @@ import ( "io" "net/http" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) func readBody(resp *http.Response, out io.Writer, archiveName string, platform versions.PlatformItem) error { @@ -57,7 +57,7 @@ func readBody(resp *http.Response, out io.Writer, archiveName string, platform v return fmt.Errorf("hash encoding %s not implemented", platform.Hash.Encoding) } if sum != platform.Hash.Value { - return fmt.Errorf("checksum mismatch for %s: %s (computed) != %s (reported)", archiveName, sum, platform.Hash.Value) + return fmt.Errorf("%w for %s: %s (computed) != %s (reported)", ErrChecksumMismatch, archiveName, sum, platform.Hash.Value) } } else if _, err := io.Copy(out, resp.Body); err != nil { return fmt.Errorf("unable to download %s: %w", archiveName, err) diff --git a/pkg/envtest/setup/setup-envtest.go b/pkg/envtest/setup/setup-envtest.go new file mode 100644 index 0000000000..4a8d9fbffb --- /dev/null +++ b/pkg/envtest/setup/setup-envtest.go @@ -0,0 +1,69 @@ +package setup + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/cleanup" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/list" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/sideload" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/use" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// List implements the list workflow for listing local and remote versions +func List(ctx context.Context, version string, options ...list.Option) ([]list.Result, error) { + spec, err := readSpec(version) + if err != nil { + return nil, err + } + + return list.List(ctx, spec, options...) +} + +// Use implements the use workflow for selecting and using a version of the environment. +// +// It will download a remote version if required (and options allow), and return the path to the binary asset directory. +func Use(ctx context.Context, version string, options ...use.Option) (use.Result, error) { + spec, err := readSpec(version) + if err != nil { + return use.Result{}, err + } + + return use.Use(ctx, spec, options...) +} + +// Cleanup implements the cleanup workflow for removing versions of the environment. +func Cleanup(ctx context.Context, version string, options ...cleanup.Option) (cleanup.Result, error) { + spec, err := readSpec(version) + if err != nil { + return cleanup.Result{}, err + } + + return cleanup.Cleanup(ctx, spec, options...) +} + +// Sideload reads a binary package from an input stream, and stores it where Use can find it +func Sideload(ctx context.Context, version string, options ...sideload.Option) error { + spec, err := readSpec(version) + if err != nil { + return err + } + + return sideload.Sideload(ctx, spec, options...) +} + +func readSpec(version string) (versions.Spec, error) { + switch version { + case "", "latest": + return versions.LatestVersion, nil + case "latest-on-disk": + return versions.AnyVersion, nil + default: + v, err := versions.FromExpr(version) + if err != nil { + return versions.Spec{}, fmt.Errorf("version must be a valid version, or simply 'latest' or 'latest-on-disk', but got %q: %w", version, err) + } + return v, nil + } +} diff --git a/pkg/envtest/setup/sideload/config.go b/pkg/envtest/setup/sideload/config.go new file mode 100644 index 0000000000..6857ff8855 --- /dev/null +++ b/pkg/envtest/setup/sideload/config.go @@ -0,0 +1,59 @@ +package sideload + +import ( + "io" + "os" + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + envOpts []env.Option + input io.Reader + platform versions.Platform +} + +// Option is a functional option for configuring the sideload process. +type Option func(*config) + +// WithEnvOptions configures the environment options for sideloading. +func WithEnvOptions(options ...env.Option) Option { + return func(cfg *config) { + cfg.envOpts = append(cfg.envOpts, options...) + } +} + +// WithInput configures the source to read the binary package from +func WithInput(input io.Reader) Option { + return func(cfg *config) { + cfg.input = input + } +} + +// WithPlatform sets the target OS and architecture for the sideload. +func WithPlatform(os string, arch string) Option { + return func(cfg *config) { + cfg.platform = versions.Platform{ + OS: os, + Arch: arch, + } + } +} + +func configure(options ...Option) config { + cfg := config{ + input: os.Stdin, + platform: versions.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }, + } + + for _, option := range options { + option(&cfg) + } + + return cfg +} diff --git a/pkg/envtest/setup/sideload/sideload.go b/pkg/envtest/setup/sideload/sideload.go new file mode 100644 index 0000000000..192f049db4 --- /dev/null +++ b/pkg/envtest/setup/sideload/sideload.go @@ -0,0 +1,40 @@ +package sideload + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// Sideload reads a binary package from an input stream and stores it in the environment store. +func Sideload(ctx context.Context, version versions.Spec, options ...Option) error { + cfg := configure(options...) + + if !version.IsConcrete() || cfg.platform.IsWildcard() { + return fmt.Errorf("must specify a concrete version and platform to sideload; got version %s, platform %s", version, cfg.platform) + } + + env, err := env.New(cfg.envOpts...) + if err != nil { + return err + } + + if err := env.Store.Initialize(ctx); err != nil { + return err + } + + log, err := logr.FromContext(ctx) + if err != nil { + return err + } + log.Info("sideloading from input stream", "version", version, "platform", cfg.platform) + if err := env.Store.Add(ctx, store.Item{Version: *version.AsConcrete(), Platform: cfg.platform}, cfg.input); err != nil { + return fmt.Errorf("sideload item to disk: %w", err) + } + + return nil +} diff --git a/pkg/envtest/setup/sideload/sideload_test.go b/pkg/envtest/setup/sideload/sideload_test.go new file mode 100644 index 0000000000..a447a25733 --- /dev/null +++ b/pkg/envtest/setup/sideload/sideload_test.go @@ -0,0 +1,76 @@ +package sideload_test + +import ( + "bytes" + "context" + "io" + "path/filepath" + "runtime" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/sideload" + "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, "Sideload Suite") +} + +var _ = Describe("Sideload", func() { + var ( + prefix = "a-test-package" + input io.Reader + ) + BeforeEach(func() { + contents, err := testhelpers.ContentsFor(prefix) + Expect(err).NotTo(HaveOccurred()) + + input = bytes.NewReader(contents) + }) + + It("should fail if a non-concrete version is given", func() { + err := sideload.Sideload(ctx, versions.LatestVersion) + Expect(err).To(HaveOccurred()) + }) + + It("should fail if a non-concrete platform is given", func() { + err := sideload.Sideload(ctx, versions.Spec{Selector: &versions.Concrete{Major: 1, Minor: 2, Patch: 3}}, sideload.WithPlatform("*", "*")) + Expect(err).To(HaveOccurred()) + }) + + It("should load the given tarball into our store as the given version", func() { + v := &versions.Concrete{Major: 1, Minor: 2, Patch: 3} + store := testhelpers.NewMockStore() + Expect(sideload.Sideload( + ctx, + versions.Spec{Selector: v}, + sideload.WithInput(input), + sideload.WithEnvOptions(env.WithStore(store)), + )).To(Succeed()) + + baseName := versions.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH}.BaseName(*v) + expectedPath := filepath.Join("k8s", baseName, prefix) + + outFile, err := store.Root.Open(expectedPath) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(outFile.Close) + contents, err := io.ReadAll(outFile) + Expect(err).NotTo(HaveOccurred()) + Expect(contents).To(HavePrefix(prefix)) + }) +}) diff --git a/tools/setup-envtest/store/helpers.go b/pkg/envtest/setup/store/helpers.go similarity index 100% rename from tools/setup-envtest/store/helpers.go rename to pkg/envtest/setup/store/helpers.go diff --git a/tools/setup-envtest/store/store.go b/pkg/envtest/setup/store/store.go similarity index 93% rename from tools/setup-envtest/store/store.go rename to pkg/envtest/setup/store/store.go index 6001eb2a4e..88acde4131 100644 --- a/tools/setup-envtest/store/store.go +++ b/pkg/envtest/setup/store/store.go @@ -17,7 +17,7 @@ import ( "github.com/go-logr/logr" "github.com/spf13/afero" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) // TODO(directxman12): error messages don't show full path, which is gonna make @@ -99,7 +99,7 @@ func (s *Store) List(ctx context.Context, matching Filter) ([]Item, error) { if err := s.eachItem(ctx, matching, func(_ string, item Item) { res = append(res, item) }); err != nil { - return nil, fmt.Errorf("unable to list version-platform pairs in store: %w", err) + return nil, fmt.Errorf("%w in store: %w", ErrUnableToList, err) } sort.Slice(res, func(i, j int) bool { @@ -182,7 +182,7 @@ func (s *Store) Add(ctx context.Context, item Item, contents io.Reader) (resErr return err } } - if err != nil && !errors.Is(err, io.EOF) { //nolint:govet + if !errors.Is(err, io.EOF) { return fmt.Errorf("unable to finish un-tar-ing the downloaded archive: %w", err) } log.V(1).Info("unpacked archive") @@ -195,6 +195,10 @@ func (s *Store) Add(ctx context.Context, item Item, contents io.Reader) (resErr return nil } +// ErrUnableToList signals that something went wrong when listing items in the store. +// It is typically wrapped together with some more details. +var ErrUnableToList = errors.New("unable to list version-platform pairs") + // Remove removes all items matching the given filter. // // It returns a list of the successfully removed items (even in the case @@ -212,12 +216,12 @@ func (s *Store) Remove(ctx context.Context, matching Filter) ([]Item, error) { if err := s.removeItem(s.unpackedPath(name)); err != nil { log.Error(err, "unable to make existing version-platform dir writable to clean it up", "path", name) - savedErr = fmt.Errorf("unable to remove version-platform pair %s (dir %s): %w", item, name, err) + savedErr = errors.Join(savedErr, fmt.Errorf("unable to remove version-platform pair %s (dir %s): %w", item, name, err)) return // don't mark this as removed in the report } removed = append(removed, item) }); err != nil { - return removed, fmt.Errorf("unable to list version-platform pairs to figure out what to delete: %w", err) + return removed, fmt.Errorf("%w to figure out what to delete: %w", ErrUnableToList, err) } if savedErr != nil { return removed, savedErr @@ -226,7 +230,7 @@ func (s *Store) Remove(ctx context.Context, matching Filter) ([]Item, error) { } // Path returns an actual path that case be used to access this item. -func (s *Store) Path(item Item) (string, error) { +func (s *Store) Path(item Item) string { path := s.unpackedPath(item.dirName()) // NB(directxman12): we need root's realpath because RealPath only // looks at its own path, and so thus doesn't prepend the underlying @@ -234,7 +238,7 @@ func (s *Store) Path(item Item) (string, error) { // // Technically, if we're fed something that's double wrapped as root, // this'll be wrong, but this is basically as much as we can do - return afero.FullBaseFsPath(path.(*afero.BasePathFs), ""), nil + return afero.FullBaseFsPath(path.(*afero.BasePathFs), "") } // unpackedBase returns the directory in which item dirs lives. diff --git a/tools/setup-envtest/store/store_suite_test.go b/pkg/envtest/setup/store/store_suite_test.go similarity index 100% rename from tools/setup-envtest/store/store_suite_test.go rename to pkg/envtest/setup/store/store_suite_test.go diff --git a/tools/setup-envtest/store/store_test.go b/pkg/envtest/setup/store/store_test.go similarity index 98% rename from tools/setup-envtest/store/store_test.go rename to pkg/envtest/setup/store/store_test.go index f0d83a1f79..a00bcbb2ec 100644 --- a/tools/setup-envtest/store/store_test.go +++ b/pkg/envtest/setup/store/store_test.go @@ -29,8 +29,8 @@ import ( . "github.com/onsi/gomega" "github.com/spf13/afero" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) const ( 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/package.go b/pkg/envtest/setup/testhelpers/package.go new file mode 100644 index 0000000000..cfeeda85e3 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/package.go @@ -0,0 +1,52 @@ +package testhelpers + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/md5" //nolint:gosec + "crypto/rand" + "encoding/base64" + "fmt" + "path/filepath" +) + +func ContentsFor(filename string) ([]byte, error) { //nolint:revive + var chunk [1024 * 48]byte // 1.5 times our chunk read size in GetVersion + copy(chunk[:], filename) + if _, err := rand.Read(chunk[len(filename):]); err != nil { + return nil, err + } + + out := new(bytes.Buffer) + gzipWriter := gzip.NewWriter(out) + + tarWriter := tar.NewWriter(gzipWriter) + + if err := tarWriter.WriteHeader(&tar.Header{ + Name: filepath.Join("kubebuilder/bin", filename), + Size: int64(len(chunk)), + Mode: 0777, // so we can check that we fix this later + }); err != nil { + return nil, fmt.Errorf("write tar header: %w", err) + } + if _, err := tarWriter.Write(chunk[:]); err != nil { + return nil, fmt.Errorf("write tar contents: %w", err) + } + + // can't defer these, because they need to happen before out.Bytes() below + tarWriter.Close() + gzipWriter.Close() + + return out.Bytes(), nil +} + +func verWith(name string, contents []byte) Item { + res := Item{ + Meta: BucketObject{Name: name}, + Contents: contents, + } + hash := md5.Sum(res.Contents) //nolint:gosec + res.Meta.Hash = base64.StdEncoding.EncodeToString(hash[:]) + return res +} diff --git a/pkg/envtest/setup/testhelpers/remote.go b/pkg/envtest/setup/testhelpers/remote.go new file mode 100644 index 0000000000..fc1a117ea7 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/remote.go @@ -0,0 +1,140 @@ +package testhelpers + +import ( + "errors" + "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"` +} + +// Item represents a single object in the mock server. +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, error) { + res := make([]Item, len(names)) + if contents == nil { + contents = make(map[string]Item, len(RemoteNames)) + } + + var errs error + for i, name := range names { + if item, ok := contents[name]; ok { + res[i] = item + continue + } + + chunk, err := ContentsFor(name) + if err != nil { + errs = errors.Join(errs, err) + continue + } + + item := verWith(name, chunk) + contents[name] = item + res[i] = item + } + + if errs != nil { + return nil, errs + } + + return res, nil +} + +// 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(items ...Item) (addr string, shutdown func(), err error) { + if items == nil { + versions, err := makeContents(RemoteNames) + if err != nil { + return "", nil, err + } + items = versions + } + + server := ghttp.NewServer() + + list := objectList{Items: make([]BucketObject, len(items))} + for i, ver := range items { + 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, nil +} diff --git a/pkg/envtest/setup/testhelpers/store.go b/pkg/envtest/setup/testhelpers/store.go new file mode 100644 index 0000000000..532f855cf4 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/store.go @@ -0,0 +1,73 @@ +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 is a list of versions that the test helpers make available in the local store + 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, "a", "good", "version"), 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "a", "good", "version", "kube-apiserver"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "a", "good", "version", "kubectl"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "a", "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)} +} diff --git a/pkg/envtest/setup/use/config.go b/pkg/envtest/setup/use/config.go new file mode 100644 index 0000000000..f5d78614f9 --- /dev/null +++ b/pkg/envtest/setup/use/config.go @@ -0,0 +1,71 @@ +package use + +import ( + "cmp" + "os" + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + platform versions.Platform + assetPath string + noDownload bool + forceDownload bool + verifySum bool + + envOpts []env.Option +} + +// Option is a functional option for configuring the use workflow +type Option func(*config) + +// WithAssetsAt sets the path to the assets directory. +func WithAssetsAt(dir string) Option { + return func(c *config) { c.assetPath = dir } +} + +// WithAssetsFromEnv sets the path to the assets directory from the environment. +func WithAssetsFromEnv(useEnv bool) Option { + return func(c *config) { + if useEnv { + c.assetPath = cmp.Or(os.Getenv(env.KubebuilderAssetsEnvVar), c.assetPath) + } + } +} + +// ForceDownload forces the download of the specified version, even if it's already present. +func ForceDownload(force bool) Option { return func(c *config) { c.forceDownload = force } } + +// NoDownload ensures only local versions are considered +func NoDownload(noDownload bool) Option { return func(c *config) { c.noDownload = noDownload } } + +// WithPlatform sets the target OS and architecture for the download. +func WithPlatform(os string, arch string) Option { + return func(c *config) { c.platform = versions.Platform{OS: os, Arch: arch} } +} + +// WithEnvOptions provides options for the env.Env used by the workflow +func WithEnvOptions(opts ...env.Option) Option { + return func(c *config) { c.envOpts = append(c.envOpts, opts...) } +} + +// VerifySum turns on md5 verification of the downloaded package +func VerifySum(verify bool) Option { return func(c *config) { c.verifySum = verify } } + +func configure(options ...Option) *config { + cfg := &config{ + platform: versions.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }, + } + + for _, opt := range options { + opt(cfg) + } + + return cfg +} diff --git a/pkg/envtest/setup/use/use.go b/pkg/envtest/setup/use/use.go new file mode 100644 index 0000000000..c7457f5142 --- /dev/null +++ b/pkg/envtest/setup/use/use.go @@ -0,0 +1,95 @@ +package use + +import ( + "context" + "errors" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// Result summarizes the output of the Use workflow +type Result struct { + Version versions.Spec + Platform versions.Platform + Hash *versions.Hash + Path string +} + +// ErrNoMatchingVersion is returned when the spec matches no available +// version; available is defined both by versions being published at all, +// but also by other options such as NoDownload. +var ErrNoMatchingVersion = errors.New("no matching version found") + +// Use selects an appropriate version based on the user's spec, downloads it if needed, +// and returns the path to the binary asset directory. +func Use(ctx context.Context, version versions.Spec, options ...Option) (Result, error) { + cfg := configure(options...) + + env, err := env.New(cfg.envOpts...) + if err != nil { + return Result{}, err + } + + if cfg.assetPath != "" { + if v, ok := env.TryUseAssetsFromPath(ctx, version, cfg.assetPath); ok { + return Result{ + Version: v, + Platform: cfg.platform, + Path: cfg.assetPath, + }, nil + } + } + + selectedLocal, err := env.SelectLocalVersion(ctx, version, cfg.platform) + if err != nil { + return Result{}, err + } + + if cfg.noDownload { + if selectedLocal != (store.Item{}) { + return toResult(env, selectedLocal, nil), nil + } + + return Result{}, fmt.Errorf("%w: no local version matching %s found, but you specified NoDownload()", ErrNoMatchingVersion, version) + } + + if !cfg.forceDownload && !version.CheckLatest && selectedLocal != (store.Item{}) { + return toResult(env, selectedLocal, nil), nil + } + + selectedVersion, selectedPlatform, err := env.SelectRemoteVersion(ctx, version, cfg.platform) + if err != nil { + return Result{}, fmt.Errorf("%w: %w", ErrNoMatchingVersion, err) + } + + if selectedLocal != (store.Item{}) && !selectedVersion.NewerThan(selectedLocal.Version) { + return Result{ + Path: env.PathTo(&selectedLocal.Version, selectedLocal.Platform), + Version: versions.Spec{Selector: selectedLocal.Version}, + Platform: selectedLocal.Platform, + }, nil + } + + if err := env.FetchRemoteVersion(ctx, selectedVersion, selectedPlatform, cfg.verifySum); err != nil { + return Result{}, err + } + + return Result{ + Version: versions.Spec{Selector: *selectedVersion}, + Platform: selectedPlatform.Platform, + Path: env.PathTo(selectedVersion, selectedPlatform.Platform), + Hash: selectedPlatform.Hash, + }, nil +} + +func toResult(env *env.Env, item store.Item, hash *versions.Hash) Result { + return Result{ + Version: versions.Spec{Selector: item.Version}, + Platform: item.Platform, + Path: env.PathTo(&item.Version, item.Platform), + Hash: hash, + } +} diff --git a/pkg/envtest/setup/use/use_test.go b/pkg/envtest/setup/use/use_test.go new file mode 100644 index 0000000000..eb237ea0ba --- /dev/null +++ b/pkg/envtest/setup/use/use_test.go @@ -0,0 +1,344 @@ +package use_test + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "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/use" + "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, "Use Suite") +} + +var _ = Describe("Use", func() { + var ( + defaultEnvOpts []env.Option + version = versions.Spec{ + Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, + } + ) + JustBeforeEach(func() { + addr, shutdown, err := testhelpers.NewServer() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(shutdown) + + s := testhelpers.NewMockStore() + + defaultEnvOpts = []env.Option{ + env.WithClient(&remote.GCSClient{ //nolint:staticcheck + Log: testLog.WithName("test-remote-client"), + Bucket: "kubebuilder-tools-test", + Server: addr, + Insecure: true, + }), + env.WithStore(s), + env.WithFS(afero.NewIOFS(s.Root)), + } + }) + + Context("when useEnv is set", func() { + It("should fall back to normal behavior when the env is not set", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(version)) + Expect(result.Path).To(HaveSuffix("/1.16.0-linux-amd64"), "should fall back to a local version") + }) + + It("should fall back to normal behavior if binaries are missing", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt(".test-binaries/missing-binaries"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(version), "should fall back to a local version") + Expect(result.Path).To(HaveSuffix("/1.16.0-linux-amd64")) + }) + + It("should use the value of the env if it contains the right binaries", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt("a/good/version"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(versions.AnyVersion)) + Expect(result.Path).To(HaveSuffix("/good/version")) + }) + + It("should not try to check the version of the binaries", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt("wrong/version"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(versions.AnyVersion)) + Expect(result.Path).To(Equal("wrong/version")) + }) + + It("should not need to contact the network", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt("a/good/version"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(versions.AnyVersion)) + Expect(result.Path).To(HaveSuffix("/good/version")) + }) + }) + + Context("when downloads are disabled", func() { + It("should error if no matches are found locally", func() { + _, err := use.Use( + ctx, + versions.Spec{Selector: versions.Concrete{Major: 9001}}, + use.NoDownload(true), + use.WithPlatform("*", "*"), + // ensures tests panic if we try to connect to the network + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + It("should settle for the latest local match if latest is requested", func() { + result, err := use.Use( + ctx, + versions.Spec{ + CheckLatest: true, + Selector: versions.PatchSelector{ + Major: 1, + Minor: 16, + Patch: versions.AnyPoint, + }, + }, + use.WithPlatform("*", "*"), + use.NoDownload(true), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 2}})) + }) + }) + + Context("if latest is requested", func() { + It("should contact the network to see if there's anything newer", func() { + result, err := use.Use( + ctx, + versions.Spec{ + CheckLatest: true, + Selector: versions.PatchSelector{ + Major: 1, + Minor: 16, + Patch: versions.AnyPoint, + }, + }, + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 4}})) + }) + + It("should still use the latest local if the network doesn't have anything newer", func() { + result, err := use.Use( + ctx, + versions.Spec{ + CheckLatest: true, + Selector: versions.PatchSelector{ + Major: 1, + Minor: 14, + Patch: versions.AnyPoint, + }, + }, + use.WithPlatform("linux", "amd64"), + use.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 14, Patch: 26}})) + }) + }) + + It("should check for a local match first", func() { + result, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.TildeSelector{ + Concrete: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, + }, + }, + use.WithPlatform("linux", "amd64"), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 1}})) + }) + + It("should fall back to the network if no local matches are found", func() { + result, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.TildeSelector{ + Concrete: versions.Concrete{Major: 1, Minor: 19, Patch: 0}, + }, + }, + use.WithPlatform("linux", "amd64"), + use.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 19, Patch: 2}})) + }) + + It("should error out if no matches can be found anywhere", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{Major: 1, Minor: 13, Patch: 0}, + }, + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + It("should skip local version matches with non-matching platform", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{Minor: 1, Major: 16, Patch: 2}, + }, + use.WithPlatform("linux", "amd64"), + use.NoDownload(true), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + It("should skip remote version matches with non-matching platform", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{Minor: 1, Major: 11, Patch: 1}, + }, + use.WithPlatform("linux", "amd64"), + use.NoDownload(true), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + Context("with an invalid checksum", func() { + var client remote.Client + BeforeEach(func() { + name := "kubebuilder-tools-86.75.309-linux-amd64.tar.gz" + contents, err := testhelpers.ContentsFor(name) + Expect(err).NotTo(HaveOccurred()) + + server, stop, err := testhelpers.NewServer(testhelpers.Item{ + Meta: testhelpers.BucketObject{ + Name: name, + Hash: "not the right one!", + }, + Contents: contents, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(stop) + + client = &remote.GCSClient{ //nolint:staticcheck + Bucket: "kubebuilder-tools-test", + Server: server, + Insecure: true, + Log: testLog.WithName("test-remote-client"), + } + }) + + When("validating the checksum", func() { + It("should fail with an appropriate error", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{ + Major: 86, + Minor: 75, + Patch: 309, + }, + }, + use.VerifySum(true), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(client))...), + ) + + Expect(err).To(MatchError(remote.ErrChecksumMismatch)) + }) + }) + + When("not validating checksum", func() { + It("should return the version without error", func() { + result, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{ + Major: 86, + Minor: 75, + Patch: 309, + }, + }, + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(client))...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 86, Minor: 75, Patch: 309}})) + }) + }) + }) +}) diff --git a/tools/setup-envtest/versions/misc_test.go b/pkg/envtest/setup/versions/misc_test.go similarity index 99% rename from tools/setup-envtest/versions/misc_test.go rename to pkg/envtest/setup/versions/misc_test.go index dcb87be8b2..d486e2c8ce 100644 --- a/tools/setup-envtest/versions/misc_test.go +++ b/pkg/envtest/setup/versions/misc_test.go @@ -20,7 +20,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - . "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + . "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) var _ = Describe("Concrete", func() { diff --git a/tools/setup-envtest/versions/parse.go b/pkg/envtest/setup/versions/parse.go similarity index 91% rename from tools/setup-envtest/versions/parse.go rename to pkg/envtest/setup/versions/parse.go index 21d38bb345..c89a98560d 100644 --- a/tools/setup-envtest/versions/parse.go +++ b/pkg/envtest/setup/versions/parse.go @@ -5,6 +5,7 @@ package versions import ( "fmt" + "path/filepath" "regexp" "strconv" ) @@ -118,3 +119,15 @@ func PatchSelectorFromMatch(match []string, re *regexp.Regexp) PatchSelector { Patch: patch, } } + +// FromPath extracts a version from a path, which is assumed to be a +// to a directory containing kubebuilder binary assets. +func FromPath(path string) (*Concrete, error) { + baseName := filepath.Base(path) + ver, _ := ExtractWithPlatform(VersionPlatformRE, baseName) + if ver == nil { + return nil, fmt.Errorf("unable to extract version from %q", path) + } + + return ver, nil +} diff --git a/tools/setup-envtest/versions/parse_test.go b/pkg/envtest/setup/versions/parse_test.go similarity index 98% rename from tools/setup-envtest/versions/parse_test.go rename to pkg/envtest/setup/versions/parse_test.go index 062fdcc6c8..1705637668 100644 --- a/tools/setup-envtest/versions/parse_test.go +++ b/pkg/envtest/setup/versions/parse_test.go @@ -20,7 +20,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - . "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + . "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) func patchSel(x, y int, z PointVersion) PatchSelector { diff --git a/tools/setup-envtest/versions/platform.go b/pkg/envtest/setup/versions/platform.go similarity index 100% rename from tools/setup-envtest/versions/platform.go rename to pkg/envtest/setup/versions/platform.go diff --git a/tools/setup-envtest/versions/selectors_test.go b/pkg/envtest/setup/versions/selectors_test.go similarity index 99% rename from tools/setup-envtest/versions/selectors_test.go rename to pkg/envtest/setup/versions/selectors_test.go index 8357d41c80..046996d1a4 100644 --- a/tools/setup-envtest/versions/selectors_test.go +++ b/pkg/envtest/setup/versions/selectors_test.go @@ -20,7 +20,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - . "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + . "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) var _ = Describe("Selectors", func() { diff --git a/tools/setup-envtest/versions/version.go b/pkg/envtest/setup/versions/version.go similarity index 97% rename from tools/setup-envtest/versions/version.go rename to pkg/envtest/setup/versions/version.go index 582ed7794e..0bf87afcc8 100644 --- a/tools/setup-envtest/versions/version.go +++ b/pkg/envtest/setup/versions/version.go @@ -171,6 +171,11 @@ func (s *Spec) MakeConcrete(ver Concrete) { s.CheckLatest = false } +// IsConcrete checks if the underlying selector is a concrete version. +func (s Spec) IsConcrete() bool { + return s.AsConcrete() != nil +} + // AsConcrete returns the underlying selector as a concrete version, if // possible. func (s Spec) AsConcrete() *Concrete { diff --git a/tools/setup-envtest/versions/versions_suite_test.go b/pkg/envtest/setup/versions/versions_suite_test.go similarity index 100% rename from tools/setup-envtest/versions/versions_suite_test.go rename to pkg/envtest/setup/versions/versions_suite_test.go diff --git a/tools/setup-envtest/env/env.go b/tools/setup-envtest/env/env.go deleted file mode 100644 index 24857916d7..0000000000 --- a/tools/setup-envtest/env/env.go +++ /dev/null @@ -1,482 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2021 The Kubernetes Authors - -package env - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "path/filepath" - "sort" - "strings" - "text/tabwriter" - - "github.com/go-logr/logr" - "github.com/spf13/afero" // too bad fs.FS isn't writable :-/ - - "sigs.k8s.io/controller-runtime/tools/setup-envtest/remote" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" -) - -// Env represents an environment for downloading and otherwise manipulating -// envtest binaries. -// -// In general, the methods will use the Exit{,Cause} functions from this package -// to indicate errors. Catch them with a `defer HandleExitWithCode()`. -type Env struct { - // the following *must* be set on input - - // Platform is our current platform - Platform versions.PlatformItem - - // VerifySum indicates whether we should run checksums. - VerifySum bool - // NoDownload forces us to not contact remote services, - // looking only at local files instead. - NoDownload bool - // ForceDownload forces us to ignore local files and always - // contact remote services & re-download. - ForceDownload bool - - // UseDeprecatedGCS signals if the GCS client is used. - // Note: This will be removed together with remote.GCSClient. - UseDeprecatedGCS bool - - // Client is our remote client for contacting remote services. - Client remote.Client - - // Log allows us to log. - Log logr.Logger - - // the following *may* be set on input, or may be discovered - - // Version is the version(s) that we want to download - // (may be automatically retrieved later on). - Version versions.Spec - - // Store is used to load/store entries to/from disk. - Store *store.Store - - // FS is the file system to read from/write to for provisioning temp files - // for storing the archives temporarily. - FS afero.Afero - - // Out is the place to write output text to - Out io.Writer - - // manualPath is the manually discovered path from PathMatches, if - // a non-store path was used. It'll be printed by PrintInfo if present. - manualPath string -} - -// CheckCoherence checks that this environment has filled-out, coherent settings -// (e.g. NoDownload & ForceDownload aren't both set). -func (e *Env) CheckCoherence() { - if e.NoDownload && e.ForceDownload { - Exit(2, "cannot both skip downloading *and* force re-downloading") - } - - if e.Platform.OS == "" || e.Platform.Arch == "" { - Exit(2, "must specify non-empty OS and arch (did you specify bad --os or --arch values?)") - } -} - -func (e *Env) filter() store.Filter { - return store.Filter{Version: e.Version, Platform: e.Platform.Platform} -} - -func (e *Env) item() store.Item { - concreteVer := e.Version.AsConcrete() - if concreteVer == nil || e.Platform.IsWildcard() { - panic("no platform/version set") // unexpected, print stack trace - } - return store.Item{Version: *concreteVer, Platform: e.Platform.Platform} -} - -// ListVersions prints out all available versions matching this Env's -// platform & version selector (respecting NoDownload to figure -// out whether or not to match remote versions). -func (e *Env) ListVersions(ctx context.Context) { - out := tabwriter.NewWriter(e.Out, 4, 4, 2, ' ', 0) - defer out.Flush() - localVersions, err := e.Store.List(ctx, e.filter()) - if err != nil { - ExitCause(2, err, "unable to list installed versions") - } - for _, item := range localVersions { - // already filtered by onDiskVersions - fmt.Fprintf(out, "(installed)\tv%s\t%s\n", item.Version, item.Platform) - } - - if e.NoDownload { - return - } - - remoteVersions, err := e.Client.ListVersions(ctx) - if err != nil { - ExitCause(2, err, "unable list to available versions") - } - - for _, set := range remoteVersions { - if !e.Version.Matches(set.Version) { - continue - } - sort.Slice(set.Platforms, func(i, j int) bool { - return orderPlatforms(set.Platforms[i].Platform, set.Platforms[j].Platform) - }) - for _, plat := range set.Platforms { - if e.Platform.Matches(plat.Platform) { - fmt.Fprintf(out, "(available)\tv%s\t%s\n", set.Version, plat) - } - } - } -} - -// LatestVersion returns the latest version matching our version selector and -// platform from the remote server, with the corresponding checksum for later -// use as well. -func (e *Env) LatestVersion(ctx context.Context) (versions.Concrete, versions.PlatformItem) { - vers, err := e.Client.ListVersions(ctx) - if err != nil { - ExitCause(2, err, "unable to list versions to find latest one") - } - for _, set := range vers { - if !e.Version.Matches(set.Version) { - e.Log.V(1).Info("skipping non-matching version", "version", set.Version) - continue - } - // double-check that our platform is supported - for _, plat := range set.Platforms { - // NB(directxman12): we're already iterating in order, so no - // need to check if the wildcard is latest vs any - if e.Platform.Matches(plat.Platform) && e.Version.Matches(set.Version) { - return set.Version, plat - } - } - e.Log.Info("latest version not supported for your platform, checking older ones", "version", set.Version, "platform", e.Platform) - } - - Exit(2, "unable to find a version that was supported for platform %s", e.Platform) - return versions.Concrete{}, versions.PlatformItem{} // unreachable, but Go's type system can't express the "never" type -} - -// ExistsAndValid checks if our current (concrete) version & platform -// exist on disk (unless ForceDownload is set, in which cause it always -// returns false). -// -// Must be called after EnsureVersionIsSet so that we have a concrete -// Version selected. Must have a concrete platform, or ForceDownload -// must be set. -func (e *Env) ExistsAndValid() bool { - if e.ForceDownload { - // we always want to download, so don't check here - return false - } - - if e.Platform.IsWildcard() { - Exit(2, "you must have a concrete platform with this command -- you cannot use wildcard platforms with fetch or switch") - } - - exists, err := e.Store.Has(e.item()) - if err != nil { - ExitCause(2, err, "unable to check if existing version exists") - } - - if exists { - e.Log.Info("applicable version found on disk", "version", e.Version) - } - return exists -} - -// EnsureVersionIsSet ensures that we have a non-wildcard version -// configured. -// -// If necessary, it will enumerate on-disk and remote versions to accomplish -// this, finding a version that matches our version selector and platform. -// It will always yield a concrete version, it *may* yield a concrete platform -// as well. -func (e *Env) EnsureVersionIsSet(ctx context.Context) { - if e.Version.AsConcrete() != nil { - return - } - var localVer *versions.Concrete - var localPlat versions.Platform - - items, err := e.Store.List(ctx, e.filter()) - if err != nil { - ExitCause(2, err, "unable to determine installed versions") - } - - for _, item := range items { - if !e.Version.Matches(item.Version) || !e.Platform.Matches(item.Platform) { - e.Log.V(1).Info("skipping version, doesn't match", "version", item.Version, "platform", item.Platform) - continue - } - // NB(directxman12): we're already iterating in order, so no - // need to check if the wildcard is latest vs any - ver := item.Version // copy to avoid referencing iteration variable - localVer = &ver - localPlat = item.Platform - break - } - - if e.NoDownload || !e.Version.CheckLatest { - // no version specified, but we either - // - // a) shouldn't contact remote - // b) don't care to find the absolute latest - // - // so just find the latest local version - if localVer != nil { - e.Version.MakeConcrete(*localVer) - e.Platform.Platform = localPlat - return - } - if e.NoDownload { - Exit(2, "no applicable on-disk versions for %s found, you'll have to download one, or run list -i to see what you do have", e.Platform) - } - // if we didn't ask for the latest version, but don't have anything - // available, try the internet ;-) - } - - // no version specified and we need the latest in some capacity, so find latest from remote - // so find the latest local first, then compare it to the latest remote, and use whichever - // of the two is more recent. - e.Log.Info("no version specified, finding latest") - serverVer, platform := e.LatestVersion(ctx) - - // if we're not forcing a download, and we have a newer local version, just use that - if !e.ForceDownload && localVer != nil && localVer.NewerThan(serverVer) { - e.Platform.Platform = localPlat // update our data with hash - e.Version.MakeConcrete(*localVer) - return - } - - // otherwise, use the new version from the server - e.Platform = platform // update our data with hash - e.Version.MakeConcrete(serverVer) -} - -// Fetch ensures that the requested platform and version are on disk. -// You must call EnsureVersionIsSet before calling this method. -// -// If ForceDownload is set, we always download, otherwise we only download -// if we're missing the version on disk. -func (e *Env) Fetch(ctx context.Context) { - log := e.Log.WithName("fetch") - - // if we didn't just fetch it, grab the sum to verify - if e.VerifySum && e.Platform.Hash == nil { - if err := e.Client.FetchSum(ctx, *e.Version.AsConcrete(), &e.Platform); err != nil { - ExitCause(2, err, "unable to fetch hash for requested version") - } - } - if !e.VerifySum { - e.Platform.Hash = nil // skip verification - } - - var packedPath string - - // cleanup on error (needs to be here so it will happen after the other defers) - defer e.cleanupOnError(func() { - if packedPath != "" { - e.Log.V(1).Info("cleaning up downloaded archive", "path", packedPath) - if err := e.FS.Remove(packedPath); err != nil && !errors.Is(err, fs.ErrNotExist) { - e.Log.Error(err, "unable to clean up archive path", "path", packedPath) - } - } - }) - - archiveOut, err := e.FS.TempFile("", "*-"+e.Platform.ArchiveName(e.UseDeprecatedGCS, *e.Version.AsConcrete())) - if err != nil { - ExitCause(2, err, "unable to open file to write downloaded archive to") - } - defer archiveOut.Close() - packedPath = archiveOut.Name() - log.V(1).Info("writing downloaded archive", "path", packedPath) - - if err := e.Client.GetVersion(ctx, *e.Version.AsConcrete(), e.Platform, archiveOut); err != nil { - ExitCause(2, err, "unable to download requested version") - } - log.V(1).Info("downloaded archive", "path", packedPath) - - if err := archiveOut.Sync(); err != nil { // sync before reading back - ExitCause(2, err, "unable to flush downloaded archive file") - } - if _, err := archiveOut.Seek(0, 0); err != nil { - ExitCause(2, err, "unable to jump back to beginning of archive file to unzip") - } - - if err := e.Store.Add(ctx, e.item(), archiveOut); err != nil { - ExitCause(2, err, "unable to store version to disk") - } - - log.V(1).Info("removing archive from disk", "path", packedPath) - if err := e.FS.Remove(packedPath); err != nil { - // don't bail, this isn't fatal - log.Error(err, "unable to remove downloaded archive", "path", packedPath) - } -} - -// cleanup on error cleans up if we hit an exitCode error. -// -// Use it in a defer. -func (e *Env) cleanupOnError(extraCleanup func()) { - cause := recover() - if cause == nil { - return - } - // don't panic in a panic handler - var exit *exitCode - if asExit(cause, &exit) && exit.code != 0 { - e.Log.Info("cleaning up due to error") - // we already log in the function, and don't want to panic, so - // ignore the error - extraCleanup() - } - panic(cause) // re-start the panic now that we're done -} - -// Remove removes the data for our version selector & platform from disk. -func (e *Env) Remove(ctx context.Context) { - items, err := e.Store.Remove(ctx, e.filter()) - for _, item := range items { - fmt.Fprintf(e.Out, "removed %s\n", item) - } - if err != nil { - ExitCause(2, err, "unable to remove all requested version(s)") - } -} - -// PrintInfo prints out information about a single, current version -// and platform, according to the given formatting info. -func (e *Env) PrintInfo(printFmt PrintFormat) { - // use the manual path if it's set, otherwise use the standard path - path := e.manualPath - if e.manualPath == "" { - item := e.item() - var err error - path, err = e.Store.Path(item) - if err != nil { - ExitCause(2, err, "unable to get path for version %s", item) - } - } - switch printFmt { - case PrintOverview: - fmt.Fprintf(e.Out, "Version: %s\n", e.Version) - fmt.Fprintf(e.Out, "OS/Arch: %s\n", e.Platform) - if e.Platform.Hash != nil { - fmt.Fprintf(e.Out, "%s: %s\n", e.Platform.Hash.Type, e.Platform.Hash.Value) - } - fmt.Fprintf(e.Out, "Path: %s\n", path) - case PrintPath: - fmt.Fprint(e.Out, path) // NB(directxman12): no newline -- want the bare path here - case PrintEnv: - // quote in case there are spaces, etc in the path - // the weird string below works like this: - // - you can't escape quotes in shell - // - shell strings that are next to each other are concatenated (so "a""b""c" == "abc") - // - you can intermix quote styles using the above - // - so `'"'"'` --> CLOSE_QUOTE + "'" + OPEN_QUOTE - shellQuoted := strings.ReplaceAll(path, "'", `'"'"'`) - fmt.Fprintf(e.Out, "export KUBEBUILDER_ASSETS='%s'\n", shellQuoted) - default: - panic(fmt.Sprintf("unexpected print format %v", printFmt)) - } -} - -// EnsureBaseDirs ensures that the base packed and unpacked directories -// exist. -// -// This should be the first thing called after CheckCoherence. -func (e *Env) EnsureBaseDirs(ctx context.Context) { - if err := e.Store.Initialize(ctx); err != nil { - ExitCause(2, err, "unable to make sure store is initialized") - } -} - -// Sideload takes an input stream, and loads it as if it had been a downloaded .tar.gz file -// for the current *concrete* version and platform. -func (e *Env) Sideload(ctx context.Context, input io.Reader) { - log := e.Log.WithName("sideload") - if e.Version.AsConcrete() == nil || e.Platform.IsWildcard() { - Exit(2, "must specify a concrete version and platform to sideload. Make sure you've passed a version, like 'sideload 1.21.0'") - } - log.V(1).Info("sideloading from input stream to version", "version", e.Version, "platform", e.Platform) - if err := e.Store.Add(ctx, e.item(), input); err != nil { - ExitCause(2, err, "unable to sideload item to disk") - } -} - -var ( - // expectedExecutables are the executables that are checked in PathMatches - // for non-store paths. - expectedExecutables = []string{ - "kube-apiserver", - "etcd", - "kubectl", - } -) - -// PathMatches checks if the path (e.g. from the environment variable) -// matches this version & platform selector, and if so, returns true. -func (e *Env) PathMatches(value string) bool { - e.Log.V(1).Info("checking if (env var) path represents our desired version", "path", value) - if value == "" { - // if we're unset, - return false - } - - if e.versionFromPathName(value) { - e.Log.V(1).Info("path appears to be in our store, using that info", "path", value) - return true - } - - e.Log.V(1).Info("path is not in our store, checking for binaries", "path", value) - for _, expected := range expectedExecutables { - _, err := e.FS.Stat(filepath.Join(value, expected)) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - // one of our required binaries is missing, return false - e.Log.V(1).Info("missing required binary in (env var) path", "binary", expected, "path", value) - return false - } - ExitCause(2, err, "unable to check for existence of binary %s from existing (env var) path %s", value, expected) - } - } - - // success, all binaries present - e.Log.V(1).Info("all required binaries present in (env var) path, using that", "path", value) - - // don't bother checking the version, the user explicitly asked us to use this - // we don't know the version, so set it to wildcard - e.Version = versions.AnyVersion - e.Platform.OS = "*" - e.Platform.Arch = "*" - e.manualPath = value - return true -} - -// versionFromPathName checks if the given path's last component looks like one -// of our versions, and, if so, what version it represents. If successful, -// it'll set version and platform, and return true. Otherwise it returns -// false. -func (e *Env) versionFromPathName(value string) bool { - baseName := filepath.Base(value) - ver, pl := versions.ExtractWithPlatform(versions.VersionPlatformRE, baseName) - if ver == nil { - // not a version that we can tell - return false - } - - // yay we got a version! - e.Version.MakeConcrete(*ver) - e.Platform.Platform = pl - e.manualPath = value // might be outside our store, set this just in case - - return true -} diff --git a/tools/setup-envtest/env/env_suite_test.go b/tools/setup-envtest/env/env_suite_test.go deleted file mode 100644 index 3400dd91aa..0000000000 --- a/tools/setup-envtest/env/env_suite_test.go +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2021 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package env_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/go-logr/logr" - "github.com/go-logr/zapr" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -var testLog logr.Logger - -func zapLogger() logr.Logger { - testOut := zapcore.AddSync(GinkgoWriter) - enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) - // bleh setting up logging to the ginkgo writer is annoying - zapLog := zap.New(zapcore.NewCore(enc, testOut, zap.DebugLevel), - zap.ErrorOutput(testOut), zap.Development(), zap.AddStacktrace(zap.WarnLevel)) - return zapr.NewLogger(zapLog) -} - -func TestEnv(t *testing.T) { - testLog = zapLogger() - - RegisterFailHandler(Fail) - RunSpecs(t, "Env Suite") -} diff --git a/tools/setup-envtest/env/env_test.go b/tools/setup-envtest/env/env_test.go deleted file mode 100644 index fd6e7633bd..0000000000 --- a/tools/setup-envtest/env/env_test.go +++ /dev/null @@ -1,108 +0,0 @@ -/* -Copyright 2021 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package env_test - -import ( - "bytes" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/spf13/afero" - - . "sigs.k8s.io/controller-runtime/tools/setup-envtest/env" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" -) - -var _ = Describe("Env", func() { - // Most of the rest of this is tested e2e via the workflows test, - // but there's a few things that are easier to test here. Eventually - // we should maybe move some of the tests here. - var ( - env *Env - outBuffer *bytes.Buffer - ) - BeforeEach(func() { - outBuffer = new(bytes.Buffer) - env = &Env{ - Out: outBuffer, - Log: testLog, - - Store: &store.Store{ - // use spaces and quotes to test our quote escaping below - Root: afero.NewBasePathFs(afero.NewMemMapFs(), "/kb's test store"), - }, - - // shouldn't use these, but just in case - NoDownload: true, - FS: afero.Afero{Fs: afero.NewMemMapFs()}, - } - - env.Version.MakeConcrete(versions.Concrete{ - Major: 1, Minor: 21, Patch: 3, - }) - env.Platform.Platform = versions.Platform{ - OS: "linux", Arch: "amd64", - } - }) - - Describe("printing", func() { - It("should use a manual path if one is present", func() { - By("using a manual path") - Expect(env.PathMatches("/otherstore/1.21.4-linux-amd64")).To(BeTrue()) - - By("checking that that path is printed properly") - env.PrintInfo(PrintPath) - Expect(outBuffer.String()).To(Equal("/otherstore/1.21.4-linux-amd64")) - }) - - Context("as human-readable info", func() { - BeforeEach(func() { - env.PrintInfo(PrintOverview) - }) - - It("should contain the version", func() { - Expect(outBuffer.String()).To(ContainSubstring("/kb's test store/k8s/1.21.3-linux-amd64")) - }) - It("should contain the path", func() { - Expect(outBuffer.String()).To(ContainSubstring("1.21.3")) - }) - It("should contain the platform", func() { - Expect(outBuffer.String()).To(ContainSubstring("linux/amd64")) - }) - - }) - Context("as just a path", func() { - It("should print out just the path", func() { - env.PrintInfo(PrintPath) - Expect(outBuffer.String()).To(Equal(`/kb's test store/k8s/1.21.3-linux-amd64`)) - }) - }) - - Context("as env vars", func() { - BeforeEach(func() { - env.PrintInfo(PrintEnv) - }) - It("should set KUBEBUILDER_ASSETS", func() { - Expect(outBuffer.String()).To(HavePrefix("export KUBEBUILDER_ASSETS=")) - }) - It("should quote the return path, escaping quotes to deal with spaces, etc", func() { - Expect(outBuffer.String()).To(HaveSuffix(`='/kb'"'"'s test store/k8s/1.21.3-linux-amd64'` + "\n")) - }) - }) - }) -}) diff --git a/tools/setup-envtest/env/exit.go b/tools/setup-envtest/env/exit.go deleted file mode 100644 index ae393b593b..0000000000 --- a/tools/setup-envtest/env/exit.go +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2021 The Kubernetes Authors - -package env - -import ( - "errors" - "fmt" - "os" -) - -// Exit exits with the given code and error message. -// -// Defer HandleExitWithCode in main to catch this and get the right behavior. -func Exit(code int, msg string, args ...interface{}) { - panic(&exitCode{ - code: code, - err: fmt.Errorf(msg, args...), - }) -} - -// ExitCause exits with the given code and error message, automatically -// wrapping the underlying error passed as well. -// -// Defer HandleExitWithCode in main to catch this and get the right behavior. -func ExitCause(code int, err error, msg string, args ...interface{}) { - args = append(args, err) - panic(&exitCode{ - code: code, - err: fmt.Errorf(msg+": %w", args...), - }) -} - -// exitCode is an error that indicates, on a panic, to exit with the given code -// and message. -type exitCode struct { - code int - err error -} - -func (c *exitCode) Error() string { - return fmt.Sprintf("%v (exit code %d)", c.err, c.code) -} -func (c *exitCode) Unwrap() error { - return c.err -} - -// asExit checks if the given (panic) value is an exitCode error, -// and if so stores it in the given pointer. It's roughly analogous -// to errors.As, except it works on recover() values. -func asExit(val interface{}, exit **exitCode) bool { - if val == nil { - return false - } - err, isErr := val.(error) - if !isErr { - return false - } - if !errors.As(err, exit) { - return false - } - return true -} - -// HandleExitWithCode handles panics of type exitCode, -// printing the status message and existing with the given -// exit code, or re-raising if not an exitCode error. -// -// This should be the first defer in your main function. -func HandleExitWithCode() { - if cause := recover(); CheckRecover(cause, func(code int, err error) { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(code) - }) { - panic(cause) - } -} - -// CheckRecover checks the value of cause, calling the given callback -// if it's an exitCode error. It returns true if we should re-panic -// the cause. -// -// It's mainly useful for testing, normally you'd use HandleExitWithCode. -func CheckRecover(cause interface{}, cb func(int, error)) bool { - if cause == nil { - return false - } - var exitErr *exitCode - if !asExit(cause, &exitErr) { - // re-raise if it's not an exit error - return true - } - - cb(exitErr.code, exitErr.err) - return false -} diff --git a/tools/setup-envtest/env/helpers.go b/tools/setup-envtest/env/helpers.go deleted file mode 100644 index 2c98c88d95..0000000000 --- a/tools/setup-envtest/env/helpers.go +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2021 The Kubernetes Authors - -package env - -import ( - "fmt" - - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" -) - -// orderPlatforms orders platforms by OS then arch. -func orderPlatforms(first, second versions.Platform) bool { - // sort by OS, then arch - if first.OS != second.OS { - return first.OS < second.OS - } - return first.Arch < second.Arch -} - -// PrintFormat indicates how to print out fetch and switch results. -// It's a valid pflag.Value so it can be used as a flag directly. -type PrintFormat int - -const ( - // PrintOverview prints human-readable data, - // including path, version, arch, and checksum (when available). - PrintOverview PrintFormat = iota - // PrintPath prints *only* the path, with no decoration. - PrintPath - // PrintEnv prints the path with the corresponding env variable, so that - // you can source the output like - // `source $(fetch-envtest switch -p env 1.20.x)`. - PrintEnv -) - -func (f PrintFormat) String() string { - switch f { - case PrintOverview: - return "overview" - case PrintPath: - return "path" - case PrintEnv: - return "env" - default: - panic(fmt.Sprintf("unexpected print format %d", int(f))) - } -} - -// Set sets the value of this as a flag. -func (f *PrintFormat) Set(val string) error { - switch val { - case "overview": - *f = PrintOverview - case "path": - *f = PrintPath - case "env": - *f = PrintEnv - default: - return fmt.Errorf("unknown print format %q, use one of overview|path|env", val) - } - return nil -} - -// Type is the type of this value as a flag. -func (PrintFormat) Type() string { - return "{overview|path|env}" -} diff --git a/tools/setup-envtest/go.mod b/tools/setup-envtest/go.mod index fa392021d7..cef40db698 100644 --- a/tools/setup-envtest/go.mod +++ b/tools/setup-envtest/go.mod @@ -2,28 +2,29 @@ module sigs.k8s.io/controller-runtime/tools/setup-envtest go 1.22.0 +replace sigs.k8s.io/controller-runtime => ../../ + require ( github.com/go-logr/logr v1.4.1 github.com/go-logr/zapr v1.3.0 github.com/onsi/ginkgo/v2 v2.17.1 github.com/onsi/gomega v1.32.0 - github.com/spf13/afero v1.6.0 github.com/spf13/pflag v1.0.5 go.uber.org/zap v1.26.0 - k8s.io/apimachinery v0.0.0-20240424173219-03f2f3350dc5 - sigs.k8s.io/yaml v1.3.0 + sigs.k8s.io/controller-runtime v0.0.0-00010101000000-000000000000 ) require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect - go.uber.org/multierr v1.10.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.18.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/tools/setup-envtest/go.sum b/tools/setup-envtest/go.sum index dd4281ac67..fc08f2cde6 100644 --- a/tools/setup-envtest/go.sum +++ b/tools/setup-envtest/go.sum @@ -15,58 +15,43 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.0.0-20240424173219-03f2f3350dc5 h1:l6ErQDrxBVdvr45UjLjVyvGUwiCRD7A2UF49iYm7ZAc= -k8s.io/apimachinery v0.0.0-20240424173219-03f2f3350dc5/go.mod h1:Xbr0GEGusNQhkPdkN3/WJL9E50/dq40D+fHHqjG+FL8= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/tools/setup-envtest/main.go b/tools/setup-envtest/main.go index 7e2761a4f6..5f08a48d94 100644 --- a/tools/setup-envtest/main.go +++ b/tools/setup-envtest/main.go @@ -4,22 +4,26 @@ package main import ( + "context" goflag "flag" "fmt" "os" "runtime" + "text/tabwriter" "github.com/go-logr/logr" "github.com/go-logr/zapr" - "github.com/spf13/afero" flag "github.com/spf13/pflag" "go.uber.org/zap" - envp "sigs.k8s.io/controller-runtime/tools/setup-envtest/env" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/remote" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/cleanup" + "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/sideload" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/use" + "sigs.k8s.io/controller-runtime/tools/setup-envtest/output" ) const ( @@ -43,7 +47,7 @@ var ( targetArch = flag.String("arch", runtime.GOARCH, "architecture to download for (e.g. amd64, for listing operations, use '*' to list all platforms)") // printFormat is the flag value for -p, --print. - printFormat = envp.PrintOverview + printFormat = output.PrintOverview // zapLvl is the flag value for logging verbosity. zapLvl = zap.WarnLevel @@ -70,84 +74,13 @@ func setupLogging() logr.Logger { logCfg.Level = zap.NewAtomicLevelAt(zapLvl) zapLog, err := logCfg.Build() if err != nil { - envp.ExitCause(1, err, "who logs the logger errors?") + fmt.Fprintln(os.Stderr, "who logs the logger errors?") + os.Exit(1) } return zapr.NewLogger(zapLog) } -// setupEnv initializes the environment from flags. -func setupEnv(globalLog logr.Logger, version string) *envp.Env { - log := globalLog.WithName("setup") - if *binDir == "" { - dataDir, err := store.DefaultStoreDir() - if err != nil { - envp.ExitCause(1, err, "unable to deterimine default binaries directory (use --bin-dir to manually override)") - } - - *binDir = dataDir - } - log.V(1).Info("using binaries directory", "dir", *binDir) - - var client remote.Client - if useDeprecatedGCS != nil && *useDeprecatedGCS { - client = &remote.GCSClient{ //nolint:staticcheck // deprecation accepted for now - Log: globalLog.WithName("storage-client"), - Bucket: *remoteBucket, - Server: *remoteServer, - } - log.V(1).Info("using deprecated GCS client", "bucket", *remoteBucket, "server", *remoteServer) - } else { - client = &remote.HTTPClient{ - Log: globalLog.WithName("storage-client"), - IndexURL: *index, - } - log.V(1).Info("using HTTP client", "index", *index) - } - - env := &envp.Env{ - Log: globalLog, - UseDeprecatedGCS: useDeprecatedGCS != nil && *useDeprecatedGCS, - Client: client, - VerifySum: *verify, - ForceDownload: *force, - NoDownload: *installedOnly, - Platform: versions.PlatformItem{ - Platform: versions.Platform{ - OS: *targetOS, - Arch: *targetArch, - }, - }, - FS: afero.Afero{Fs: afero.NewOsFs()}, - Store: store.NewAt(*binDir), - Out: os.Stdout, - } - - switch version { - case "", "latest": - env.Version = versions.LatestVersion - case "latest-on-disk": - // we sort by version, latest first, so this'll give us the latest on - // disk (as per the contract from env.List & store.List) - env.Version = versions.AnyVersion - env.NoDownload = true - default: - var err error - env.Version, err = versions.FromExpr(version) - if err != nil { - envp.ExitCause(1, err, "version be a valid version, or simply 'latest' or 'latest-on-disk'") - } - } - - env.CheckCoherence() - - return env -} - func main() { - // exit with appropriate error codes -- this should be the first defer so - // that it's the last one executed. - defer envp.HandleExitWithCode() - // set up flags flag.Usage = func() { name := os.Args[0] @@ -258,13 +191,14 @@ Environment Variables: if *needHelp { flag.Usage() - envp.Exit(2, "") + os.Exit(2) } // check our argument count if numArgs := flag.NArg(); numArgs < 1 || numArgs > 2 { flag.Usage() - envp.Exit(2, "please specify a command to use, and optionally a version selector") + fmt.Fprintln(os.Stderr, "please specify a command to use, and optionally a version selector") + os.Exit(2) } // set up logging @@ -275,27 +209,108 @@ Environment Variables: if flag.NArg() > 1 { version = flag.Arg(1) } - env := setupEnv(globalLog, version) + + var client remote.Client + if useDeprecatedGCS != nil && *useDeprecatedGCS { + client = &remote.GCSClient{ //nolint:staticcheck // deprecation accepted for now + Log: globalLog.WithName("storage-client"), + Bucket: *remoteBucket, + Server: *remoteServer, + } + globalLog.V(1).Info("using deprecated GCS client", "bucket", *remoteBucket, "server", *remoteServer) + } else { + client = &remote.HTTPClient{ + Log: globalLog.WithName("storage-client"), + IndexURL: *index, + } + globalLog.V(1).Info("using HTTP client", "index", *index) + } // perform our main set of actions switch action := flag.Arg(0); action { case "use": - workflows.Use{ - UseEnv: *useEnv, - PrintFormat: printFormat, - AssetsPath: os.Getenv("KUBEBUILDER_ASSETS"), - }.Do(env) + result, err := setup.Use( + logr.NewContext(context.Background(), globalLog.WithName("use")), + version, + use.WithAssetsFromEnv(*useEnv), + use.ForceDownload(*force), + use.NoDownload(*installedOnly), + use.VerifySum(*verify), + use.WithEnvOptions( + env.WithClient(client), + env.WithStoreAt(*binDir), + ), + ) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + printFormat.Sprintf(os.Stdout, + result.Version, + result.Platform, + result.Hash, + result.Path, + ) case "list": - workflows.List{}.Do(env) + results, err := setup.List( + logr.NewContext(context.Background(), globalLog.WithName("list")), + version, + list.NoDownload(*installedOnly), + list.WithEnvOptions( + env.WithClient(client), + env.WithStoreAt(*binDir), + ), + list.WithPlatform(*targetOS, *targetArch), + ) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + w := tabwriter.NewWriter(os.Stdout, 4, 4, 2, ' ', 0) + for _, result := range results { + fmt.Fprintf(w, "(%s)\tv%s\t%s\n", result.Status, result.Version, result.Platform) + } + w.Flush() case "cleanup": - workflows.Cleanup{}.Do(env) + results, err := setup.Cleanup( + logr.NewContext(context.Background(), globalLog.WithName("cleanup")), + version, + cleanup.WithEnvOptions( + env.WithClient(client), + env.WithStoreAt(*binDir), + ), + cleanup.WithPlatform(*targetOS, *targetArch), + ) + + w := tabwriter.NewWriter(os.Stdout, 4, 4, 2, ' ', 0) + for _, item := range results { + fmt.Fprintf(w, "removed\tv%s\t%s\n", item.Version, item.Platform) + } + w.Flush() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + case "sideload": - workflows.Sideload{ - Input: os.Stdin, - PrintFormat: printFormat, - }.Do(env) + if err := setup.Sideload( + logr.NewContext(context.Background(), globalLog.WithName("sideload")), + version, + sideload.WithInput(os.Stdin), + sideload.WithPlatform(*targetOS, *targetArch), + sideload.WithEnvOptions( + env.WithClient(client), + env.WithStoreAt(*binDir), + ), + ); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } default: flag.Usage() - envp.Exit(2, "unknown action %q", action) + fmt.Fprintf(os.Stderr, "unknown action %q\n", action) + os.Exit(2) } } diff --git a/tools/setup-envtest/output/output.go b/tools/setup-envtest/output/output.go new file mode 100644 index 0000000000..aaf86e73ff --- /dev/null +++ b/tools/setup-envtest/output/output.go @@ -0,0 +1,95 @@ +package output + +import ( + "fmt" + "io" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// PrintFormat indicates how to print out fetch and switch results. +// It's a valid pflag.Value so it can be used as a flag directly. +type PrintFormat int + +const ( + // PrintOverview prints human-readable data, + // including path, version, arch, and checksum (when available). + PrintOverview PrintFormat = iota + // PrintPath prints *only* the path, with no decoration. + PrintPath + // PrintEnv prints the path with the corresponding env variable, so that + // you can source the output like + // `source $(fetch-envtest switch -p env 1.20.x)`. + PrintEnv +) + +func (f PrintFormat) String() string { + switch f { + case PrintOverview: + return "overview" + case PrintPath: + return "path" + case PrintEnv: + return "env" + default: + panic(fmt.Sprintf("unexpected print format %d", int(f))) + } +} + +// Set sets the value of this as a flag. +func (f *PrintFormat) Set(val string) error { + switch val { + case "overview": + *f = PrintOverview + case "path": + *f = PrintPath + case "env": + *f = PrintEnv + default: + return fmt.Errorf("unknown print format %q, use one of overview|path|env", val) + } + return nil +} + +// Type is the type of this value as a flag. +func (PrintFormat) Type() string { + return "{overview|path|env}" +} + +// Sprintf returns the string to be printed +func (f PrintFormat) Sprintf(out io.Writer, version versions.Spec, platform versions.Platform, hash *versions.Hash, path string) (err error) { + switch f { + case PrintOverview: + if _, e := fmt.Fprintf(out, "Version: %s\n", version); e != nil { + return e + } + if _, e := fmt.Fprintf(out, "OS/Arch: %s\n", platform); e != nil { + return e + } + if hash != nil { + if _, e := fmt.Fprintf(out, "Checksum (%s/%s): %s\n", hash.Type, hash.Encoding, hash.Value); e != nil { + return e + } + } + if _, e := fmt.Fprintf(out, "Path: %s\n", path); e != nil { + return e + } + return nil + case PrintPath: + _, e := fmt.Fprint(out, path) // NB(directxman12): no newline -- want the bare path here + return e + case PrintEnv: + // quote in case there are spaces, etc in the path + // the weird string below works like this: + // - you can't escape quotes in shell + // - shell strings that are next to each other are concatenated (so "a""b""c" == "abc") + // - you can intermix quote styles using the above + // - so `'"'"'` --> CLOSE_QUOTE + "'" + OPEN_QUOTE + shellQuoted := strings.ReplaceAll(path, "'", `'"'"'`) + _, e := fmt.Fprintf(out, "export KUBEBUILDER_ASSETS='%s'\n", shellQuoted) + return e + default: + return fmt.Errorf("unexpected print format %v", f) + } +} diff --git a/tools/setup-envtest/output/output_test.go b/tools/setup-envtest/output/output_test.go new file mode 100644 index 0000000000..4d69bfd122 --- /dev/null +++ b/tools/setup-envtest/output/output_test.go @@ -0,0 +1,109 @@ +package output_test + +import ( + "bytes" + "testing" + + "github.com/go-logr/logr" + "github.com/go-logr/zapr" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" + "sigs.k8s.io/controller-runtime/tools/setup-envtest/output" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func zapLogger() logr.Logger { + testOut := zapcore.AddSync(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) +} + +var testLog logr.Logger + +func TestEnv(t *testing.T) { + testLog = zapLogger() + + RegisterFailHandler(Fail) + RunSpecs(t, "Output Suite") +} + +var _ = Describe("PrintFormat", func() { + var ( + outBuffer *bytes.Buffer + format output.PrintFormat + + version = versions.Spec{Selector: &versions.Concrete{Major: 1, Minor: 21, Patch: 3}} + platform = versions.Platform{OS: "linux", Arch: "amd64"} + hash = &versions.Hash{Type: "md5", Value: "deadbeef", Encoding: versions.HexHashEncoding} + path = "/kb's test store/k8s/1.21.3-linux-amd64" + ) + + JustBeforeEach(func() { + outBuffer = &bytes.Buffer{} + Expect(format.Sprintf( + outBuffer, + version, + platform, + hash, + path, + )).To(Succeed()) + }) + Describe("PrintOverview", func() { + BeforeEach(func() { format = output.PrintOverview }) + + It("should contain the version", func() { + Expect(outBuffer.String()).To(ContainSubstring("Version: 1.21.3")) + }) + + It("should contain the OS/Arch", func() { + Expect(outBuffer.String()).To(ContainSubstring("OS/Arch: linux/amd64")) + }) + + It("should contain the checksum", func() { + Expect(outBuffer.String()).To(ContainSubstring("Checksum (md5/hex): deadbeef")) + }) + + It("should contain the path", func() { + Expect(outBuffer.String()).To(ContainSubstring("Path: /kb's test store/k8s/1.21.3-linux-amd64")) + }) + + Context("when the checksum is empty", func() { + BeforeEach(func() { + hash = nil + }) + + It("should not contain the checksum", func() { + Expect(outBuffer.String()).NotTo(ContainSubstring("Checksum:")) + }) + }) + }) + + Describe("PrintPath", func() { + BeforeEach(func() { format = output.PrintPath }) + + It("should print out just the path", func() { + Expect(outBuffer.String()).To(Equal(path)) + }) + It("should not end with a newline", func() { + Expect(outBuffer.String()).NotTo(ContainSubstring("\n")) + }) + }) + + Describe("PrintEnv", func() { + BeforeEach(func() { format = output.PrintEnv }) + + It("should print out an export statement", func() { + Expect(outBuffer.String()).To(HavePrefix("export KUBEBUILDER_ASSETS=")) + }) + It("should quote the path, escaping quotes to deal with spaces, etc", func() { + Expect(outBuffer.String()).To(HaveSuffix(`='/kb'"'"'s test store/k8s/1.21.3-linux-amd64'` + "\n")) + }) + }) +}) diff --git a/tools/setup-envtest/remote/client.go b/tools/setup-envtest/remote/client.go deleted file mode 100644 index 24efd6daff..0000000000 --- a/tools/setup-envtest/remote/client.go +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2024 The Kubernetes Authors - -package remote - -import ( - "context" - "io" - - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" -) - -// Client is an interface to get and list envtest binary archives. -type Client interface { - ListVersions(ctx context.Context) ([]versions.Set, error) - - GetVersion(ctx context.Context, version versions.Concrete, platform versions.PlatformItem, out io.Writer) error - - FetchSum(ctx context.Context, ver versions.Concrete, pl *versions.PlatformItem) error -} diff --git a/tools/setup-envtest/workflows/workflows.go b/tools/setup-envtest/workflows/workflows.go deleted file mode 100644 index fdabd995ae..0000000000 --- a/tools/setup-envtest/workflows/workflows.go +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2021 The Kubernetes Authors - -package workflows - -import ( - "context" - "io" - - "github.com/go-logr/logr" - - envp "sigs.k8s.io/controller-runtime/tools/setup-envtest/env" -) - -// Use is a workflow that prints out information about stored -// version-platform pairs, downloading them if necessary & requested. -type Use struct { - UseEnv bool - AssetsPath string - PrintFormat envp.PrintFormat -} - -// Do executes this workflow. -func (f Use) Do(env *envp.Env) { - ctx := logr.NewContext(context.TODO(), env.Log.WithName("use")) - env.EnsureBaseDirs(ctx) - if f.UseEnv { - // the env var unconditionally - if env.PathMatches(f.AssetsPath) { - env.PrintInfo(f.PrintFormat) - return - } - } - env.EnsureVersionIsSet(ctx) - if env.ExistsAndValid() { - env.PrintInfo(f.PrintFormat) - return - } - if env.NoDownload { - envp.Exit(2, "no such version (%s) exists on disk for this architecture (%s) -- try running `list -i` to see what's on disk", env.Version, env.Platform) - } - env.Fetch(ctx) - env.PrintInfo(f.PrintFormat) -} - -// List is a workflow that lists version-platform pairs in the store -// and on the remote server that match the given filter. -type List struct{} - -// Do executes this workflow. -func (List) Do(env *envp.Env) { - ctx := logr.NewContext(context.TODO(), env.Log.WithName("list")) - env.EnsureBaseDirs(ctx) - env.ListVersions(ctx) -} - -// Cleanup is a workflow that removes version-platform pairs from the store -// that match the given filter. -type Cleanup struct{} - -// Do executes this workflow. -func (Cleanup) Do(env *envp.Env) { - ctx := logr.NewContext(context.TODO(), env.Log.WithName("cleanup")) - - env.NoDownload = true - env.ForceDownload = false - - env.EnsureBaseDirs(ctx) - env.Remove(ctx) -} - -// Sideload is a workflow that adds or replaces a version-platform pair in the -// store, using the given archive as the files. -type Sideload struct { - Input io.Reader - PrintFormat envp.PrintFormat -} - -// Do executes this workflow. -func (f Sideload) Do(env *envp.Env) { - ctx := logr.NewContext(context.TODO(), env.Log.WithName("sideload")) - - env.EnsureBaseDirs(ctx) - env.NoDownload = true - env.Sideload(ctx, f.Input) - env.PrintInfo(f.PrintFormat) -} diff --git a/tools/setup-envtest/workflows/workflows_suite_test.go b/tools/setup-envtest/workflows/workflows_suite_test.go deleted file mode 100644 index 1b487622bd..0000000000 --- a/tools/setup-envtest/workflows/workflows_suite_test.go +++ /dev/null @@ -1,46 +0,0 @@ -/* -Copyright 2021 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package workflows_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/go-logr/logr" - "github.com/go-logr/zapr" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -var testLog logr.Logger - -func zapLogger() logr.Logger { - testOut := zapcore.AddSync(GinkgoWriter) - enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) - // bleh setting up logging to the ginkgo writer is annoying - zapLog := zap.New(zapcore.NewCore(enc, testOut, zap.DebugLevel), - zap.ErrorOutput(testOut), zap.Development(), zap.AddStacktrace(zap.WarnLevel)) - return zapr.NewLogger(zapLog) -} - -func TestWorkflows(t *testing.T) { - testLog = zapLogger() - RegisterFailHandler(Fail) - RunSpecs(t, "Workflows Suite") -} diff --git a/tools/setup-envtest/workflows/workflows_test.go b/tools/setup-envtest/workflows/workflows_test.go deleted file mode 100644 index 8c4007a415..0000000000 --- a/tools/setup-envtest/workflows/workflows_test.go +++ /dev/null @@ -1,501 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2021 The Kubernetes Authors - -package workflows_test - -import ( - "bytes" - "fmt" - "io/fs" - "path/filepath" - "sort" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/onsi/gomega/ghttp" - "github.com/spf13/afero" - "k8s.io/apimachinery/pkg/util/sets" - envp "sigs.k8s.io/controller-runtime/tools/setup-envtest/env" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/remote" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" - wf "sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" -) - -func ver(major, minor, patch int) versions.Concrete { - return versions.Concrete{ - Major: major, - Minor: minor, - Patch: patch, - } -} - -func shouldHaveError() { - var err error - var code int - if cause := recover(); envp.CheckRecover(cause, func(caughtCode int, caughtErr error) { - err = caughtErr - code = caughtCode - }) { - panic(cause) - } - Expect(err).To(HaveOccurred(), "should write an error") - Expect(code).NotTo(BeZero(), "should exit with a non-zero code") -} - -const ( - testStorePath = ".teststore" -) - -const ( - gcsMode = "GCS" - httpMode = "HTTP" -) - -var _ = Describe("GCS Client", func() { - WorkflowTest(gcsMode) -}) - -var _ = Describe("HTTP Client", func() { - WorkflowTest(httpMode) -}) - -func WorkflowTest(testMode string) { - Describe("Workflows", func() { - var ( - env *envp.Env - out *bytes.Buffer - server *ghttp.Server - remoteGCSItems []item - remoteHTTPItems itemsHTTP - ) - BeforeEach(func() { - out = new(bytes.Buffer) - baseFs := afero.Afero{Fs: afero.NewMemMapFs()} - - server = ghttp.NewServer() - - var client remote.Client - switch testMode { - case gcsMode: - client = &remote.GCSClient{ //nolint:staticcheck // deprecation accepted for now - Log: testLog.WithName("gcs-client"), - Bucket: "kubebuilder-tools-test", // test custom bucket functionality too - Server: server.Addr(), - Insecure: true, // no https in httptest :-( - } - case httpMode: - client = &remote.HTTPClient{ - Log: testLog.WithName("http-client"), - IndexURL: fmt.Sprintf("http://%s/%s", server.Addr(), "envtest-releases.yaml"), - } - } - - env = &envp.Env{ - Log: testLog, - VerifySum: true, // on by default - FS: baseFs, - Store: &store.Store{Root: afero.NewBasePathFs(baseFs, testStorePath)}, - Out: out, - Platform: versions.PlatformItem{ // default - Platform: versions.Platform{ - OS: "linux", - Arch: "amd64", - }, - }, - Client: client, - } - - fakeStore(env.FS, testStorePath) - remoteGCSItems = remoteVersionsGCS - remoteHTTPItems = remoteVersionsHTTP - }) - JustBeforeEach(func() { - switch testMode { - case gcsMode: - handleRemoteVersionsGCS(server, remoteGCSItems) - case httpMode: - handleRemoteVersionsHTTP(server, remoteHTTPItems) - } - }) - AfterEach(func() { - server.Close() - server = nil - }) - - Describe("use", func() { - var flow wf.Use - BeforeEach(func() { - // some defaults for most tests - env.Version = versions.Spec{ - Selector: ver(1, 16, 0), - } - flow = wf.Use{ - PrintFormat: envp.PrintPath, - } - }) - - It("should initialize the store if it doesn't exist", func() { - Expect(env.FS.RemoveAll(testStorePath)).To(Succeed()) - // need to set this to a valid remote version cause our store is now empty - env.Version = versions.Spec{Selector: ver(1, 16, 4)} - flow.Do(env) - Expect(env.FS.Stat(testStorePath)).NotTo(BeNil()) - }) - - Context("when use env is set", func() { - BeforeEach(func() { - flow.UseEnv = true - }) - It("should fall back to normal behavior when the env is not set", func() { - flow.Do(env) - Expect(out.String()).To(HaveSuffix("/1.16.0-linux-amd64"), "should fall back to a local version") - }) - It("should fall back to normal behavior if binaries are missing", func() { - flow.AssetsPath = ".teststore/missing-binaries" - flow.Do(env) - Expect(out.String()).To(HaveSuffix("/1.16.0-linux-amd64"), "should fall back to a local version") - }) - It("should use the value of the env if it contains the right binaries", func() { - flow.AssetsPath = ".teststore/good-version" - flow.Do(env) - Expect(out.String()).To(Equal(flow.AssetsPath)) - }) - It("should not try and check the version of the binaries", func() { - flow.AssetsPath = ".teststore/wrong-version" - flow.Do(env) - Expect(out.String()).To(Equal(flow.AssetsPath)) - }) - It("should not need to contact the network", func() { - server.Close() - flow.AssetsPath = ".teststore/good-version" - flow.Do(env) - // expect to not get a panic -- if we do, it'll cause the test to fail - }) - }) - - Context("when downloads are disabled", func() { - BeforeEach(func() { - env.NoDownload = true - server.Close() - }) - - // It("should not contact the network") is a gimme here, because we - // call server.Close() above. - - It("should error if no matches are found locally", func() { - defer shouldHaveError() - env.Version.Selector = versions.Concrete{Major: 9001} - flow.Do(env) - }) - It("should settle for the latest local match if latest is requested", func() { - env.Version = versions.Spec{ - CheckLatest: true, - Selector: versions.PatchSelector{ - Major: 1, - Minor: 16, - Patch: versions.AnyPoint, - }, - } - - flow.Do(env) - - // latest on "server" is 1.16.4, shouldn't use that - Expect(out.String()).To(HaveSuffix("/1.16.1-linux-amd64"), "should use the latest local version") - }) - }) - - Context("if latest is requested", func() { - It("should contact the network to see if there's anything newer", func() { - env.Version = versions.Spec{ - CheckLatest: true, - Selector: versions.PatchSelector{ - Major: 1, Minor: 16, Patch: versions.AnyPoint, - }, - } - flow.Do(env) - Expect(out.String()).To(HaveSuffix("/1.16.4-linux-amd64"), "should use the latest remote version") - }) - It("should still use the latest local if the network doesn't have anything newer", func() { - env.Version = versions.Spec{ - CheckLatest: true, - Selector: versions.PatchSelector{ - Major: 1, Minor: 14, Patch: versions.AnyPoint, - }, - } - - flow.Do(env) - - // latest on the server is 1.14.1, latest local is 1.14.26 - Expect(out.String()).To(HaveSuffix("/1.14.26-linux-amd64"), "should use the latest local version") - }) - }) - - It("should check local for a match first", func() { - server.Close() // confirm no network - env.Version = versions.Spec{ - Selector: versions.TildeSelector{Concrete: ver(1, 16, 0)}, - } - flow.Do(env) - // latest on the server is 1.16.4, latest local is 1.16.1 - Expect(out.String()).To(HaveSuffix("/1.16.1-linux-amd64"), "should use the latest local version") - }) - - It("should fall back to the network if no local matches are found", func() { - env.Version = versions.Spec{ - Selector: versions.TildeSelector{Concrete: ver(1, 19, 0)}, - } - flow.Do(env) - Expect(out.String()).To(HaveSuffix("/1.19.2-linux-amd64"), "should have a remote version") - }) - - It("should error out if no matches can be found anywhere", func() { - defer shouldHaveError() - env.Version = versions.Spec{ - Selector: versions.TildeSelector{Concrete: ver(0, 0, 1)}, - } - flow.Do(env) - }) - - It("should skip local versions matches with non-matching platforms", func() { - env.NoDownload = true // so we get an error - defer shouldHaveError() - env.Version = versions.Spec{ - // has non-matching local versions - Selector: ver(1, 13, 0), - } - - flow.Do(env) - }) - - It("should skip remote version matches with non-matching platforms", func() { - defer shouldHaveError() - env.Version = versions.Spec{ - // has a non-matching remote version - Selector: versions.TildeSelector{Concrete: ver(1, 11, 1)}, - } - flow.Do(env) - }) - - Describe("verifying the checksum", func() { - BeforeEach(func() { - remoteGCSItems = append(remoteGCSItems, item{ - meta: bucketObject{ - Name: "kubebuilder-tools-86.75.309-linux-amd64.tar.gz", - Hash: "nottherightone!", - }, - contents: remoteGCSItems[0].contents, // need a valid tar.gz file to not error from that - }) - // Recreate remoteHTTPItems to not impact others tests. - remoteHTTPItems = makeContentsHTTP(remoteNamesHTTP) - remoteHTTPItems.index.Releases["v86.75.309"] = map[string]remote.Archive{ - "envtest-v86.75.309-linux-amd64.tar.gz": { - SelfLink: "not used in this test", - Hash: "nottherightone!", - }, - } - // need a valid tar.gz file to not error from that - remoteHTTPItems.contents["envtest-v86.75.309-linux-amd64.tar.gz"] = remoteHTTPItems.contents["envtest-v1.10-darwin-amd64.tar.gz"] - - env.Version = versions.Spec{ - Selector: ver(86, 75, 309), - } - }) - Specify("when enabled, should fail if the downloaded hash doesn't match", func() { - defer shouldHaveError() - flow.Do(env) - }) - Specify("when disabled, shouldn't check the checksum at all", func() { - env.VerifySum = false - flow.Do(env) - }) - }) - }) - - Describe("list", func() { - // split by fields so we're not matching on whitespace - listFields := func() [][]string { - resLines := strings.Split(strings.TrimSpace(out.String()), "\n") - resFields := make([][]string, len(resLines)) - for i, line := range resLines { - resFields[i] = strings.Fields(line) - } - return resFields - } - - Context("when downloads are disabled", func() { - BeforeEach(func() { - server.Close() // ensure no network - env.NoDownload = true - }) - It("should include local contents sorted by version", func() { - env.Version = versions.AnyVersion - env.Platform.Platform = versions.Platform{OS: "*", Arch: "*"} - wf.List{}.Do(env) - - Expect(listFields()).To(Equal([][]string{ - {"(installed)", "v1.17.9", "linux/amd64"}, - {"(installed)", "v1.16.2", "ifonlysingularitywasstillathing/amd64"}, - {"(installed)", "v1.16.2", "linux/yourimagination"}, - {"(installed)", "v1.16.1", "linux/amd64"}, - {"(installed)", "v1.16.0", "linux/amd64"}, - {"(installed)", "v1.14.26", "hyperwarp/pixiedust"}, - {"(installed)", "v1.14.26", "linux/amd64"}, - })) - }) - It("should skip non-matching local contents", func() { - env.Version.Selector = versions.PatchSelector{ - Major: 1, Minor: 16, Patch: versions.AnyPoint, - } - env.Platform.Arch = "*" - wf.List{}.Do(env) - - Expect(listFields()).To(Equal([][]string{ - {"(installed)", "v1.16.2", "linux/yourimagination"}, - {"(installed)", "v1.16.1", "linux/amd64"}, - {"(installed)", "v1.16.0", "linux/amd64"}, - })) - }) - }) - Context("when downloads are enabled", func() { - Context("when sorting", func() { - BeforeEach(func() { - // shorten the list a bit for expediency - remoteGCSItems = remoteGCSItems[:7] - - // Recreate remoteHTTPItems to not impact others tests. - remoteHTTPItems = makeContentsHTTP(remoteNamesHTTP) - // Also only keep the first 7 items. - // Get the first 7 archive names - var archiveNames []string - for _, release := range remoteHTTPItems.index.Releases { - for archiveName := range release { - archiveNames = append(archiveNames, archiveName) - } - } - sort.Strings(archiveNames) - archiveNamesSet := sets.Set[string]{}.Insert(archiveNames[:7]...) - // Delete all other archives - for _, release := range remoteHTTPItems.index.Releases { - for archiveName := range release { - if !archiveNamesSet.Has(archiveName) { - delete(release, archiveName) - } - } - } - }) - It("should sort local & remote by version", func() { - env.Version = versions.AnyVersion - env.Platform.Platform = versions.Platform{OS: "*", Arch: "*"} - wf.List{}.Do(env) - - Expect(listFields()).To(Equal([][]string{ - {"(installed)", "v1.17.9", "linux/amd64"}, - {"(installed)", "v1.16.2", "ifonlysingularitywasstillathing/amd64"}, - {"(installed)", "v1.16.2", "linux/yourimagination"}, - {"(installed)", "v1.16.1", "linux/amd64"}, - {"(installed)", "v1.16.0", "linux/amd64"}, - {"(installed)", "v1.14.26", "hyperwarp/pixiedust"}, - {"(installed)", "v1.14.26", "linux/amd64"}, - {"(available)", "v1.11.1", "potato/cherrypie"}, - {"(available)", "v1.11.0", "darwin/amd64"}, - {"(available)", "v1.11.0", "linux/amd64"}, - {"(available)", "v1.10.1", "darwin/amd64"}, - {"(available)", "v1.10.1", "linux/amd64"}, - })) - }) - }) - It("should skip non-matching remote contents", func() { - env.Version.Selector = versions.PatchSelector{ - Major: 1, Minor: 16, Patch: versions.AnyPoint, - } - env.Platform.Arch = "*" - wf.List{}.Do(env) - - Expect(listFields()).To(Equal([][]string{ - {"(installed)", "v1.16.2", "linux/yourimagination"}, - {"(installed)", "v1.16.1", "linux/amd64"}, - {"(installed)", "v1.16.0", "linux/amd64"}, - {"(available)", "v1.16.4", "linux/amd64"}, - })) - }) - }) - }) - - Describe("cleanup", func() { - BeforeEach(func() { - server.Close() // ensure no network - flow := wf.Cleanup{} - env.Version = versions.AnyVersion - env.Platform.Arch = "*" - flow.Do(env) - }) - - It("should remove matching versions from the store & keep non-matching ones", func() { - entries, err := env.FS.ReadDir(".teststore/k8s") - Expect(err).NotTo(HaveOccurred(), "should be able to read the store") - Expect(entries).To(ConsistOf( - WithTransform(fs.FileInfo.Name, Equal("1.16.2-ifonlysingularitywasstillathing-amd64")), - WithTransform(fs.FileInfo.Name, Equal("1.14.26-hyperwarp-pixiedust")), - )) - }) - }) - - Describe("sideload", func() { - var ( - flow wf.Sideload - ) - - var expectedPrefix string - if testMode == gcsMode { - // remote version fake contents are prefixed by the - // name for easier debugging, so we can use that here - expectedPrefix = remoteVersionsGCS[0].meta.Name - } - if testMode == httpMode { - // hard coding to one of the archives in remoteVersionsHTTP as we can't pick the "first" of a map. - expectedPrefix = "envtest-v1.10-darwin-amd64.tar.gz" - } - - BeforeEach(func() { - server.Close() // ensure no network - var content []byte - if testMode == gcsMode { - content = remoteVersionsGCS[0].contents - } - if testMode == httpMode { - content = remoteVersionsHTTP.contents[expectedPrefix] - } - flow.Input = bytes.NewReader(content) - flow.PrintFormat = envp.PrintPath - }) - It("should initialize the store if it doesn't exist", func() { - env.Version.Selector = ver(1, 10, 0) - Expect(env.FS.RemoveAll(testStorePath)).To(Succeed()) - flow.Do(env) - Expect(env.FS.Stat(testStorePath)).NotTo(BeNil()) - }) - It("should fail if a non-concrete version is given", func() { - defer shouldHaveError() - env.Version = versions.LatestVersion - flow.Do(env) - }) - It("should fail if a non-concrete platform is given", func() { - defer shouldHaveError() - env.Version.Selector = ver(1, 10, 0) - env.Platform.Arch = "*" - flow.Do(env) - }) - It("should load the given gizipped tarball into our store as the given version", func() { - env.Version.Selector = ver(1, 10, 0) - flow.Do(env) - baseName := env.Platform.BaseName(*env.Version.AsConcrete()) - expectedPath := filepath.Join(".teststore/k8s", baseName, "some-file") - outContents, err := env.FS.ReadFile(expectedPath) - Expect(err).NotTo(HaveOccurred(), "should be able to load the unzipped file") - Expect(string(outContents)).To(HavePrefix(expectedPrefix), "should have the debugging prefix") - }) - }) - }) -} diff --git a/tools/setup-envtest/workflows/workflows_testutils_test.go b/tools/setup-envtest/workflows/workflows_testutils_test.go deleted file mode 100644 index e796e5d16c..0000000000 --- a/tools/setup-envtest/workflows/workflows_testutils_test.go +++ /dev/null @@ -1,357 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2021 The Kubernetes Authors - -package workflows_test - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "crypto/md5" //nolint:gosec - "crypto/rand" - "crypto/sha512" - "encoding/base64" - "encoding/hex" - "fmt" - "net/http" - "path/filepath" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/onsi/gomega/ghttp" - "github.com/spf13/afero" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/remote" - "sigs.k8s.io/yaml" - - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" -) - -var ( - remoteNamesGCS = []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", - } - remoteVersionsGCS = makeContentsGCS(remoteNamesGCS) - - remoteNamesHTTP = remote.Index{ - Releases: map[string]remote.Release{ - "v1.10.0": map[string]remote.Archive{ - "envtest-v1.10-darwin-amd64.tar.gz": {}, - "envtest-v1.10-linux-amd64.tar.gz": {}, - }, - "v1.10.1": map[string]remote.Archive{ - "envtest-v1.10.1-darwin-amd64.tar.gz": {}, - "envtest-v1.10.1-linux-amd64.tar.gz": {}, - }, - "v1.11.0": map[string]remote.Archive{ - "envtest-v1.11.0-darwin-amd64.tar.gz": {}, - "envtest-v1.11.0-linux-amd64.tar.gz": {}, - }, - "v1.11.1": map[string]remote.Archive{ - "envtest-v1.11.1-potato-cherrypie.tar.gz": {}, - }, - "v1.12.3": map[string]remote.Archive{ - "envtest-v1.12.3-darwin-amd64.tar.gz": {}, - "envtest-v1.12.3-linux-amd64.tar.gz": {}, - }, - "v1.13.1": map[string]remote.Archive{ - "envtest-v1.13.1-darwin-amd64.tar.gz": {}, - "envtest-v1.13.1-linux-amd64.tar.gz": {}, - }, - "v1.14.1": map[string]remote.Archive{ - "envtest-v1.14.1-darwin-amd64.tar.gz": {}, - "envtest-v1.14.1-linux-amd64.tar.gz": {}, - }, - "v1.15.5": map[string]remote.Archive{ - "envtest-v1.15.5-darwin-amd64.tar.gz": {}, - "envtest-v1.15.5-linux-amd64.tar.gz": {}, - }, - "v1.16.4": map[string]remote.Archive{ - "envtest-v1.16.4-darwin-amd64.tar.gz": {}, - "envtest-v1.16.4-linux-amd64.tar.gz": {}, - }, - "v1.17.9": map[string]remote.Archive{ - "envtest-v1.17.9-darwin-amd64.tar.gz": {}, - "envtest-v1.17.9-linux-amd64.tar.gz": {}, - }, - "v1.19.0": map[string]remote.Archive{ - "envtest-v1.19.0-darwin-amd64.tar.gz": {}, - "envtest-v1.19.0-linux-amd64.tar.gz": {}, - }, - "v1.19.2": map[string]remote.Archive{ - "envtest-v1.19.2-darwin-amd64.tar.gz": {}, - "envtest-v1.19.2-linux-amd64.tar.gz": {}, - "envtest-v1.19.2-linux-arm64.tar.gz": {}, - "envtest-v1.19.2-linux-ppc64le.tar.gz": {}, - }, - "v1.20.2": map[string]remote.Archive{ - "envtest-v1.20.2-darwin-amd64.tar.gz": {}, - "envtest-v1.20.2-linux-amd64.tar.gz": {}, - "envtest-v1.20.2-linux-arm64.tar.gz": {}, - "envtest-v1.20.2-linux-ppc64le.tar.gz": {}, - }, - }, - } - remoteVersionsHTTP = makeContentsHTTP(remoteNamesHTTP) - - // keep this sorted. - localVersions = []versions.Set{ - {Version: ver(1, 17, 9), Platforms: []versions.PlatformItem{ - {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, - }}, - {Version: ver(1, 16, 2), Platforms: []versions.PlatformItem{ - {Platform: versions.Platform{OS: "linux", Arch: "yourimagination"}}, - {Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, - }}, - {Version: ver(1, 16, 1), Platforms: []versions.PlatformItem{ - {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, - }}, - {Version: ver(1, 16, 0), Platforms: []versions.PlatformItem{ - {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, - }}, - {Version: ver(1, 14, 26), Platforms: []versions.PlatformItem{ - {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, - {Platform: versions.Platform{OS: "hyperwarp", Arch: "pixiedust"}}, - }}, - } -) - -type item struct { - meta bucketObject - contents []byte -} - -// 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"` -} - -func makeContentsGCS(names []string) []item { - res := make([]item, len(names)) - for i, name := range names { - 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) - } - res[i] = verWithGCS(name, chunk[:]) - } - return res -} - -func verWithGCS(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 handleRemoteVersionsGCS(server *ghttp.Server, versions []item) { - 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) - Expect(resp.Write(ver.contents)).To(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, - )) -} - -type itemsHTTP struct { - index remote.Index - contents map[string][]byte -} - -func makeContentsHTTP(index remote.Index) itemsHTTP { - // This creates a new copy of the index so modifying the index - // in some tests doesn't affect others. - res := itemsHTTP{ - index: remote.Index{ - Releases: map[string]remote.Release{}, - }, - contents: map[string][]byte{}, - } - - for releaseVersion, releases := range index.Releases { - res.index.Releases[releaseVersion] = remote.Release{} - for archiveName := range releases { - var chunk [1024 * 48]byte // 1.5 times our chunk read size in GetVersion - copy(chunk[:], archiveName) - if _, err := rand.Read(chunk[len(archiveName):]); err != nil { - panic(err) - } - content, hash := verWithHTTP(chunk[:]) - - res.index.Releases[releaseVersion][archiveName] = remote.Archive{ - Hash: hash, - // Note: Only storing the name of the archive for now. - // This will be expanded later to a full URL once the server is running. - SelfLink: archiveName, - } - res.contents[archiveName] = content - } - } - return res -} - -func verWithHTTP(contents []byte) ([]byte, string) { - out := new(bytes.Buffer) - gzipWriter := gzip.NewWriter(out) - tarWriter := tar.NewWriter(gzipWriter) - err := tarWriter.WriteHeader(&tar.Header{ - Name: "controller-tools/envtest/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() - content := out.Bytes() - // controller-tools is using sha512 - hash := sha512.Sum512(content) - hashEncoded := hex.EncodeToString(hash[:]) - return content, hashEncoded -} - -func handleRemoteVersionsHTTP(server *ghttp.Server, items itemsHTTP) { - if server.HTTPTestServer == nil { - // Just return for test cases where server is closed in BeforeEach. Otherwise server.Addr() below panics. - return - } - - // The index from items contains only relative SelfLinks. - // finalIndex will contain the full links based on server.Addr(). - finalIndex := remote.Index{ - Releases: map[string]remote.Release{}, - } - - for releaseVersion, releases := range items.index.Releases { - finalIndex.Releases[releaseVersion] = remote.Release{} - - for archiveName, archive := range releases { - finalIndex.Releases[releaseVersion][archiveName] = remote.Archive{ - Hash: archive.Hash, - SelfLink: fmt.Sprintf("http://%s/%s", server.Addr(), archive.SelfLink), - } - content := items.contents[archiveName] - - // Note: Using the relative path from archive here instead of the full path. - server.RouteToHandler("GET", "/"+archive.SelfLink, func(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(http.StatusOK) - Expect(resp.Write(content)).To(Equal(len(content))) - }) - } - } - - indexYAML, err := yaml.Marshal(finalIndex) - Expect(err).ToNot(HaveOccurred()) - - server.RouteToHandler("GET", "/envtest-releases.yaml", ghttp.RespondWith( - http.StatusOK, - indexYAML, - )) -} - -func fakeStore(fs afero.Afero, dir string) { - By("making the unpacked directory") - unpackedBase := filepath.Join(dir, "k8s") - Expect(fs.Mkdir(unpackedBase, 0755)).To(Succeed()) - - By("making some fake (empty) versions") - for _, set := range localVersions { - for _, plat := range set.Platforms { - Expect(fs.Mkdir(filepath.Join(unpackedBase, plat.BaseName(set.Version)), 0755)).To(Succeed()) - } - } - - By("making some fake non-store paths") - Expect(fs.Mkdir(filepath.Join(dir, "missing-binaries"), 0755)).To(Succeed()) - - Expect(fs.Mkdir(filepath.Join(dir, "wrong-version"), 0755)).To(Succeed()) - Expect(fs.WriteFile(filepath.Join(dir, "wrong-version", "kube-apiserver"), nil, 0755)).To(Succeed()) - Expect(fs.WriteFile(filepath.Join(dir, "wrong-version", "kubectl"), nil, 0755)).To(Succeed()) - Expect(fs.WriteFile(filepath.Join(dir, "wrong-version", "etcd"), nil, 0755)).To(Succeed()) - - Expect(fs.Mkdir(filepath.Join(dir, "good-version"), 0755)).To(Succeed()) - Expect(fs.WriteFile(filepath.Join(dir, "good-version", "kube-apiserver"), nil, 0755)).To(Succeed()) - Expect(fs.WriteFile(filepath.Join(dir, "good-version", "kubectl"), nil, 0755)).To(Succeed()) - Expect(fs.WriteFile(filepath.Join(dir, "good-version", "etcd"), nil, 0755)).To(Succeed()) - // TODO: put the right files -}