From 3650b9b6011903a7e84d7c9db974e3697ce7d9fb Mon Sep 17 00:00:00 2001 From: Tomas Aschan <1550920+tomasaschan@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:39:20 +0200 Subject: [PATCH] Re-implement the setup-envtest functionality in a package There are two main points of re-implementing vs just moving the code: 1. Error handling in the old code base was based on panicking and recovering, where the recover basically just read out a field from the panic value and determined the correct exit code. In a package, we want more nuanced error handling, especially in order to allow test suites to catch the errors and surface them through their own reporting mechanisms. 2. There was a lot of global state in the old code base, "hidden" in the env.Env type that was used as a receiver for all the methods. This re-implementation tries to make the state more explicit, keeping only dependencies (like the remote client and local store) in the environment, while retaining the same behavior as the previous implementation. Tests have been ported over to their respective workflow sub-packages, and a few new ones have been added to cover cases the old test suite for one reason or another did not. Thus, we can be fairly confident that the new implementation does not break old functionality, even if it is a significant rewrite. --- examples/scratch-env/go.mod | 4 +- examples/scratch-env/go.sum | 8 +- go.mod | 13 +- go.sum | 22 +- pkg/envtest/setup/cleanup/cleanup.go | 41 ++ pkg/envtest/setup/cleanup/cleanup_test.go | 97 ++++ pkg/envtest/setup/cleanup/config.go | 45 ++ pkg/envtest/setup/env/assets.go | 69 +++ pkg/envtest/setup/env/env.go | 71 +++ pkg/envtest/setup/env/local.go | 36 ++ pkg/envtest/setup/env/remote.go | 95 ++++ pkg/envtest/setup/list/config.go | 46 ++ pkg/envtest/setup/list/list.go | 81 +++ pkg/envtest/setup/list/list_test.go | 274 ++++++++++ .../envtest/setup}/remote/client.go | 7 +- .../envtest/setup}/remote/gcs_client.go | 38 +- .../envtest/setup}/remote/http_client.go | 26 +- .../envtest/setup}/remote/read_body.go | 4 +- pkg/envtest/setup/setup-envtest.go | 69 +++ pkg/envtest/setup/sideload/config.go | 59 +++ pkg/envtest/setup/sideload/sideload.go | 40 ++ pkg/envtest/setup/sideload/sideload_test.go | 76 +++ .../envtest/setup}/store/helpers.go | 0 .../envtest/setup}/store/store.go | 18 +- .../envtest/setup}/store/store_suite_test.go | 0 .../envtest/setup}/store/store_test.go | 4 +- pkg/envtest/setup/testhelpers/logging.go | 21 + pkg/envtest/setup/testhelpers/package.go | 52 ++ pkg/envtest/setup/testhelpers/remote.go | 139 +++++ pkg/envtest/setup/testhelpers/store.go | 72 +++ pkg/envtest/setup/use/config.go | 71 +++ pkg/envtest/setup/use/use.go | 95 ++++ pkg/envtest/setup/use/use_test.go | 344 ++++++++++++ .../envtest/setup}/versions/misc_test.go | 2 +- .../envtest/setup}/versions/parse.go | 13 + .../envtest/setup}/versions/parse_test.go | 2 +- .../envtest/setup}/versions/platform.go | 0 .../envtest/setup}/versions/selectors_test.go | 2 +- .../envtest/setup}/versions/version.go | 5 + .../setup}/versions/versions_suite_test.go | 0 tools/setup-envtest/env/env.go | 482 ----------------- tools/setup-envtest/env/env_suite_test.go | 47 -- tools/setup-envtest/env/env_test.go | 108 ---- tools/setup-envtest/env/exit.go | 96 ---- tools/setup-envtest/env/helpers.go | 68 --- tools/setup-envtest/go.mod | 11 +- tools/setup-envtest/go.sum | 27 +- tools/setup-envtest/main.go | 205 +++---- tools/setup-envtest/output/output.go | 95 ++++ tools/setup-envtest/output/output_test.go | 109 ++++ tools/setup-envtest/workflows/workflows.go | 87 --- .../workflows/workflows_suite_test.go | 46 -- .../setup-envtest/workflows/workflows_test.go | 501 ------------------ .../workflows/workflows_testutils_test.go | 357 ------------- 54 files changed, 2349 insertions(+), 1951 deletions(-) create mode 100644 pkg/envtest/setup/cleanup/cleanup.go create mode 100644 pkg/envtest/setup/cleanup/cleanup_test.go create mode 100644 pkg/envtest/setup/cleanup/config.go create mode 100644 pkg/envtest/setup/env/assets.go create mode 100644 pkg/envtest/setup/env/env.go create mode 100644 pkg/envtest/setup/env/local.go create mode 100644 pkg/envtest/setup/env/remote.go create mode 100644 pkg/envtest/setup/list/config.go create mode 100644 pkg/envtest/setup/list/list.go create mode 100644 pkg/envtest/setup/list/list_test.go rename {tools/setup-envtest => pkg/envtest/setup}/remote/client.go (66%) rename {tools/setup-envtest => pkg/envtest/setup}/remote/gcs_client.go (81%) rename {tools/setup-envtest => pkg/envtest/setup}/remote/http_client.go (88%) rename {tools/setup-envtest => pkg/envtest/setup}/remote/read_body.go (91%) create mode 100644 pkg/envtest/setup/setup-envtest.go create mode 100644 pkg/envtest/setup/sideload/config.go create mode 100644 pkg/envtest/setup/sideload/sideload.go create mode 100644 pkg/envtest/setup/sideload/sideload_test.go rename {tools/setup-envtest => pkg/envtest/setup}/store/helpers.go (100%) rename {tools/setup-envtest => pkg/envtest/setup}/store/store.go (93%) rename {tools/setup-envtest => pkg/envtest/setup}/store/store_suite_test.go (100%) rename {tools/setup-envtest => pkg/envtest/setup}/store/store_test.go (98%) create mode 100644 pkg/envtest/setup/testhelpers/logging.go create mode 100644 pkg/envtest/setup/testhelpers/package.go create mode 100644 pkg/envtest/setup/testhelpers/remote.go create mode 100644 pkg/envtest/setup/testhelpers/store.go create mode 100644 pkg/envtest/setup/use/config.go create mode 100644 pkg/envtest/setup/use/use.go create mode 100644 pkg/envtest/setup/use/use_test.go rename {tools/setup-envtest => pkg/envtest/setup}/versions/misc_test.go (99%) rename {tools/setup-envtest => pkg/envtest/setup}/versions/parse.go (91%) rename {tools/setup-envtest => pkg/envtest/setup}/versions/parse_test.go (98%) rename {tools/setup-envtest => pkg/envtest/setup}/versions/platform.go (100%) rename {tools/setup-envtest => pkg/envtest/setup}/versions/selectors_test.go (99%) rename {tools/setup-envtest => pkg/envtest/setup}/versions/version.go (97%) rename {tools/setup-envtest => pkg/envtest/setup}/versions/versions_suite_test.go (100%) delete mode 100644 tools/setup-envtest/env/env.go delete mode 100644 tools/setup-envtest/env/env_suite_test.go delete mode 100644 tools/setup-envtest/env/env_test.go delete mode 100644 tools/setup-envtest/env/exit.go delete mode 100644 tools/setup-envtest/env/helpers.go create mode 100644 tools/setup-envtest/output/output.go create mode 100644 tools/setup-envtest/output/output_test.go delete mode 100644 tools/setup-envtest/workflows/workflows.go delete mode 100644 tools/setup-envtest/workflows/workflows_suite_test.go delete mode 100644 tools/setup-envtest/workflows/workflows_test.go delete mode 100644 tools/setup-envtest/workflows/workflows_testutils_test.go 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..7fde7ce57c --- /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() { + cleanup.Cleanup( + ctx, + version, + cleanup.WithPlatform("linux", "amd64"), + cleanup.WithEnvOptions(defaultEnvOpts...), + ) + }) + + 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..dc6def31a5 --- /dev/null +++ b/pkg/envtest/setup/env/env.go @@ -0,0 +1,71 @@ +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" +) + +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{ + Bucket: remote.DefaultBucket, + Server: remote.DefaultServer, + 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..0469ea6cb0 --- /dev/null +++ b/pkg/envtest/setup/env/remote.go @@ -0,0 +1,95 @@ +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 { + if err := e.FetchSum(ctx, *version, &platform); err != nil { + return fmt.Errorf("fetch md5 sum for version %s, platform %s: %w", version, platform.Platform, err) + } + } else if !verifySum { + // turn off checksum verification + platform.Hash = nil + } + + _, useGCS := e.Client.(*remote.GCSClient) + 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..6f5fd749a1 --- /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{ + 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/tools/setup-envtest/remote/client.go b/pkg/envtest/setup/remote/client.go similarity index 66% rename from tools/setup-envtest/remote/client.go rename to pkg/envtest/setup/remote/client.go index 24efd6daff..0cf3b902d3 100644 --- a/tools/setup-envtest/remote/client.go +++ b/pkg/envtest/setup/remote/client.go @@ -5,11 +5,14 @@ package remote import ( "context" + "errors" "io" - "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" ) +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) @@ -17,4 +20,6 @@ type Client interface { 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 + + 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 81% rename from tools/setup-envtest/remote/gcs_client.go rename to pkg/envtest/setup/remote/gcs_client.go index 85f321d5c5..23ea7b6054 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. // @@ -200,3 +212,27 @@ func (c *GCSClient) FetchSum(ctx context.Context, ver versions.Concrete, pl *ver } return nil } + +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 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 88% rename from tools/setup-envtest/remote/http_client.go rename to pkg/envtest/setup/remote/http_client.go index 0339654a82..2a33fa8731 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" ) @@ -173,6 +173,30 @@ func (c *HTTPClient) FetchSum(ctx context.Context, version versions.Concrete, pl return fmt.Errorf("unable to find archive for %s (%s,%s)", version, platform.OS, platform.Arch) } +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) { indexURL := c.IndexURL if indexURL == "" { 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..c08540ee14 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/remote.go @@ -0,0 +1,139 @@ +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"` +} + +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..b511915602 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/store.go @@ -0,0 +1,72 @@ +package testhelpers + +import ( + "path/filepath" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/spf13/afero" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var ( + // keep this sorted. + + LocalVersions = []versions.Set{ + {Version: versions.Concrete{Major: 1, Minor: 17, Patch: 9}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 2}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "yourimagination"}}, + {Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 1}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 14, Patch: 26}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Platform: versions.Platform{OS: "hyperwarp", Arch: "pixiedust"}}, + }}, + } +) + +func initializeFakeStore(fs afero.Afero, dir string) { + ginkgo.By("making the unpacked directory") + unpackedBase := filepath.Join(dir, "k8s") + gomega.Expect(fs.Mkdir(unpackedBase, 0755)).To(gomega.Succeed()) + + ginkgo.By("making some fake (empty) versions") + for _, set := range LocalVersions { + for _, plat := range set.Platforms { + gomega.Expect(fs.Mkdir(filepath.Join(unpackedBase, plat.BaseName(set.Version)), 0755)).To(gomega.Succeed()) + } + } + + ginkgo.By("making some fake non-store paths") + gomega.Expect(fs.Mkdir(filepath.Join(dir, "missing", "binaries"), 0755)).To(gomega.Succeed()) + + gomega.Expect(fs.Mkdir(filepath.Join(dir, "wrong", "version"), 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong", "version", "kube-apiserver"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong", "version", "kubectl"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong", "version", "etcd"), nil, 0755)).To(gomega.Succeed()) + + gomega.Expect(fs.Mkdir(filepath.Join(dir, "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..1c9d8a9edb --- /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{ + 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{ + 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/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 -}