diff --git a/examples/scratch-env/go.mod b/examples/scratch-env/go.mod index f94627e2c9..e42fb21b3e 100644 --- a/examples/scratch-env/go.mod +++ b/examples/scratch-env/go.mod @@ -27,7 +27,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.4.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 @@ -48,7 +48,7 @@ require ( golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.20.0 // indirect golang.org/x/text v0.15.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/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/examples/scratch-env/go.sum b/examples/scratch-env/go.sum index ae89e5d368..48e555fde4 100644 --- a/examples/scratch-env/go.sum +++ b/examples/scratch-env/go.sum @@ -46,8 +46,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-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -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/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= @@ -141,8 +141,8 @@ 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.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.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 0c01863883..9cad6de98a 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,8 @@ require ( sigs.k8s.io/yaml v1.4.0 ) +require github.com/spf13/afero v1.11.0 + require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect @@ -52,7 +54,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-20240525223248-4bfdf5a9a2af // 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,10 +83,10 @@ require ( golang.org/x/sync v0.7.0 // indirect golang.org/x/term v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect - golang.org/x/time v0.3.0 // indirect + golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.21.0 // 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 4b5dac6166..755c151908 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-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -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/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= @@ -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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +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= @@ -183,8 +185,8 @@ 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.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.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= @@ -197,12 +199,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/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/go.work b/go.work new file mode 100644 index 0000000000..7200888820 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.22.4 + +use ( + . + ./examples/scratch-env + ./tools/setup-envtest +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000000..e59fb19cea --- /dev/null +++ b/go.work.sum @@ -0,0 +1,190 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= +cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.152.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 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..a1ef941859 --- /dev/null +++ b/pkg/envtest/setup/cleanup/cleanup_test.go @@ -0,0 +1,98 @@ +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() { + _, err := cleanup.Cleanup( + ctx, + version, + cleanup.WithPlatform("linux", "amd64"), + cleanup.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should remove matching versions", func() { + items, err := s.List(ctx, store.Filter{Version: version, Platform: versions.Platform{OS: "linux", Arch: "amd64"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(items).To(BeEmpty(), "found matching versions after cleanup") + }) + + It("should not remove non-matching versions", func() { + items, err := s.List(ctx, store.Filter{Version: versions.AnyVersion, Platform: versions.Platform{OS: "*", Arch: "*"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(items).To(ContainElements(nonMatching), "non-matching items were affected") + }) + }) +}) diff --git a/pkg/envtest/setup/cleanup/config.go b/pkg/envtest/setup/cleanup/config.go new file mode 100644 index 0000000000..696b90da72 --- /dev/null +++ b/pkg/envtest/setup/cleanup/config.go @@ -0,0 +1,45 @@ +package cleanup + +import ( + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + envOpts []env.Option + platform versions.Platform +} + +// Option is a functional option for configuring the cleanup process. +type Option func(*config) + +// WithEnvOptions adds options to the environment setup. +func WithEnvOptions(opts ...env.Option) Option { + return func(cfg *config) { + cfg.envOpts = append(cfg.envOpts, opts...) + } +} + +// WithPlatform sets the platform to use for cleanup. +func WithPlatform(os string, arch string) Option { + return func(cfg *config) { + cfg.platform = versions.Platform{OS: os, Arch: arch} + } +} + +func configure(options ...Option) *config { + cfg := &config{ + platform: versions.Platform{ + Arch: runtime.GOARCH, + OS: runtime.GOOS, + }, + } + + for _, opt := range options { + opt(cfg) + } + + return cfg +} diff --git a/pkg/envtest/setup/env/assets.go b/pkg/envtest/setup/env/assets.go new file mode 100644 index 0000000000..e1754f7322 --- /dev/null +++ b/pkg/envtest/setup/env/assets.go @@ -0,0 +1,69 @@ +package env + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path/filepath" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var expectedExecutables = []string{ + "kube-apiserver", + "etcd", + "kubectl", +} + +// TryUseAssetsFromPath attempts to use the assets from the provided path if they match the spec. +// If they do not, or if some executable is missing, it returns an empty string. +func (e *Env) TryUseAssetsFromPath(ctx context.Context, spec versions.Spec, path string) (versions.Spec, bool) { + v, err := versions.FromPath(path) + if err != nil { + ok, checkErr := e.hasAllExecutables(path) + log.FromContext(ctx).Info("has all executables", "ok", ok, "err", checkErr) + if checkErr != nil { + log.FromContext(ctx).Error(errors.Join(err, checkErr), "Failed checking if assets path has all binaries, ignoring", "path", path) + return versions.Spec{}, false + } else if ok { + // If the path has all executables, we can use it even if we can't parse the version. + // The user explicitly asked for this path, so set the version to a wildcard so that + // it passes checks downstream. + return versions.AnyVersion, true + } + + log.FromContext(ctx).Error(errors.Join(err, errors.New("some required binaries missing")), "Unable to use assets from path, ignoring", "path", path) + return versions.Spec{}, false + } + + if !spec.Matches(*v) { + log.FromContext(ctx).Error(nil, "Assets path does not match spec, ignoring", "path", path, "spec", spec) + return versions.Spec{}, false + } + + if ok, err := e.hasAllExecutables(path); err != nil { + log.FromContext(ctx).Error(err, "Failed checking if assets path has all binaries, ignoring", "path", path) + return versions.Spec{}, false + } else if !ok { + log.FromContext(ctx).Error(nil, "Assets path is missing some executables, ignoring", "path", path) + return versions.Spec{}, false + } + + return versions.Spec{Selector: v}, true +} + +func (e *Env) hasAllExecutables(path string) (bool, error) { + for _, expected := range expectedExecutables { + _, err := e.FS.Open(filepath.Join(path, expected)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("check for existence of %s binary in %s: %w", expected, path, err) + } + } + + return true, nil +} diff --git a/pkg/envtest/setup/env/env.go b/pkg/envtest/setup/env/env.go new file mode 100644 index 0000000000..225d1b2df4 --- /dev/null +++ b/pkg/envtest/setup/env/env.go @@ -0,0 +1,72 @@ +package env + +import ( + "io/fs" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" +) + +// KubebuilderAssetsEnvVar is the environment variable that can be used to override the default local storage location +const KubebuilderAssetsEnvVar = "KUBEBUILDER_ASSETS" + +// Env encapsulates the environment dependencies. +type Env struct { + *store.Store + remote.Client + fs.FS +} + +// Option is a functional option for configuring an environment +type Option func(*Env) + +// WithStoreAt sets the path to the store directory. +func WithStoreAt(dir string) Option { + return func(c *Env) { c.Store = store.NewAt(dir) } +} + +// WithStore allows injecting a envured store. +func WithStore(store *store.Store) Option { + return func(c *Env) { c.Store = store } +} + +// WithClient allows injecting a envured remote client. +func WithClient(client remote.Client) Option { return func(c *Env) { c.Client = client } } + +// WithFS allows injecting a configured fs.FS, e.g. for mocking. +// TODO: fix this so it's actually used! +func WithFS(fs fs.FS) Option { + return func(c *Env) { + c.FS = fs + } +} + +// New returns a new environment, configured with the provided options. +// +// If no options are provided, it will be created with a production store.Store and remote.Client +// and an OS file system. +func New(options ...Option) (*Env, error) { + env := &Env{ + // this is the minimal configuration that won't panic + Client: &remote.GCSClient{ //nolint:staticcheck + Bucket: remote.DefaultBucket, //nolint:staticcheck + Server: remote.DefaultServer, //nolint:staticcheck + Log: logr.Discard(), + }, + } + + for _, option := range options { + option(env) + } + + if env.Store == nil { + dir, err := store.DefaultStoreDir() + if err != nil { + return nil, err + } + env.Store = store.NewAt(dir) + } + + return env, nil +} diff --git a/pkg/envtest/setup/env/local.go b/pkg/envtest/setup/env/local.go new file mode 100644 index 0000000000..fd50e2933f --- /dev/null +++ b/pkg/envtest/setup/env/local.go @@ -0,0 +1,36 @@ +package env + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// SelectLocalVersion returns the latest local version that matches the provided spec and platform, +// or nil if no such version is available. +// +// Note that a nil error does not guarantee that a version was found! +func (e *Env) SelectLocalVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (store.Item, error) { + localVersions, err := e.Store.List(ctx, store.Filter{Version: spec, Platform: platform}) + if err != nil { + return store.Item{}, err + } + // NB(tomasaschan): this assumes the following of the contract for store.List + // * only results matching the provided filter are returned + // * they are returned sorted, with the newest version first in the list + // Within these constraints, if the slice is non-empty, the first item is the one, + // we want, and there's no need to iterate through the items again. + if len(localVersions) > 0 { + // copy to avoid holding on to the entire slice + result := localVersions[0] + return result, nil + } + + return store.Item{}, nil +} + +// PathTo returns the local path to the assets directory for the provided version and platform +func (e *Env) PathTo(version *versions.Concrete, platform versions.Platform) string { + return e.Store.Path(store.Item{Version: *version, Platform: platform}) +} diff --git a/pkg/envtest/setup/env/remote.go b/pkg/envtest/setup/env/remote.go new file mode 100644 index 0000000000..826d50f88e --- /dev/null +++ b/pkg/envtest/setup/env/remote.go @@ -0,0 +1,98 @@ +package env + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// SelectRemoteVersion finds the latest remote version that matches the provided spec and platform. +func (e *Env) SelectRemoteVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (*versions.Concrete, versions.PlatformItem, error) { + vs, err := e.Client.ListVersions(ctx) + if err != nil { + return nil, versions.PlatformItem{}, err + } + + for _, v := range vs { + if !spec.Matches(v.Version) { + log.FromContext(ctx).V(1).Info("skipping non-matching version", "version", v.Version) + continue + } + + for _, p := range v.Platforms { + if platform.Matches(p.Platform) { + // copy to avoid holding on to the entire slice + ver := v.Version + return &ver, p, nil + } + } + + plats := make([]versions.Platform, 0) + for _, p := range v.Platforms { + plats = append(plats, p.Platform) + } + + log.FromContext(ctx).Info("version not available for your platform; skipping", "version", v.Version, "platforms", plats) + } + + return nil, versions.PlatformItem{}, fmt.Errorf("no applicable packages found for version %s and platform %s", spec, platform) +} + +// FetchRemoteVersion downloads the specified version and platform binaries and extracts them to the appropriate path +// +// If verifySum is true, it will also fetch the md5 sum for the version and platform and check the hashsum of the downloaded archive. +func (e *Env) FetchRemoteVersion(ctx context.Context, version *versions.Concrete, platform versions.PlatformItem, verifySum bool) error { + if verifySum && platform.Hash == nil { + hash, err := e.FetchSum(ctx, *version, platform.Platform) + if err != nil { + return fmt.Errorf("fetch md5 sum for version %s, platform %s: %w", version, platform.Platform, err) + } + + platform.Hash = hash + } else if !verifySum { + // turn off checksum verification + platform.Hash = nil + } + + _, useGCS := e.Client.(*remote.GCSClient) //nolint:staticcheck + archiveOut, err := os.CreateTemp("", "*-"+platform.ArchiveName(useGCS, *version)) + if err != nil { + return fmt.Errorf("open temporary download location: %w", err) + } + // cleanup defer needs to be the first one defined, so it's the last one to run + packedPath := "" + defer func() { + if packedPath != "" { + if err := os.Remove(packedPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + log.FromContext(ctx).V(1).Error(err, "Unable to clean up %s", packedPath) + } + } + }() + defer archiveOut.Close() + + packedPath = archiveOut.Name() + if err := e.Client.GetVersion(ctx, *version, platform, archiveOut); err != nil { + return fmt.Errorf("download archive: %w", err) + } + + if err := archiveOut.Sync(); err != nil { + return fmt.Errorf("flush downloaded file: %w", err) + } + + if _, err := archiveOut.Seek(0, 0); err != nil { + return fmt.Errorf("jump to start of archive: %w", err) + } + + if err := e.Store.Add(ctx, store.Item{Version: *version, Platform: platform.Platform}, archiveOut); err != nil { + return fmt.Errorf("store version to disk: %w", err) + } + + return nil +} diff --git a/pkg/envtest/setup/list/config.go b/pkg/envtest/setup/list/config.go new file mode 100644 index 0000000000..dfc6fa561e --- /dev/null +++ b/pkg/envtest/setup/list/config.go @@ -0,0 +1,46 @@ +package list + +import ( + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + platform versions.Platform + localOnly bool + envOpts []env.Option +} + +// Option is a functional option for configuring the list workflow +type Option func(*config) + +// WithEnvOptions provides options for the env.Env used by the workflow +func WithEnvOptions(opts ...env.Option) Option { + return func(c *config) { c.envOpts = append(c.envOpts, opts...) } +} + +// WithPlatform sets the target OS and architecture for the download. +func WithPlatform(os string, arch string) Option { + return func(c *config) { c.platform = versions.Platform{OS: os, Arch: arch} } +} + +// NoDownload ensures only local versions are considered +func NoDownload(noDownload bool) Option { return func(c *config) { c.localOnly = noDownload } } + +func configure(options ...Option) *config { + cfg := &config{} + + for _, opt := range options { + opt(cfg) + } + + if cfg.platform == (versions.Platform{}) { + cfg.platform = versions.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + } + return cfg +} diff --git a/pkg/envtest/setup/list/list.go b/pkg/envtest/setup/list/list.go new file mode 100644 index 0000000000..16df8951b4 --- /dev/null +++ b/pkg/envtest/setup/list/list.go @@ -0,0 +1,81 @@ +package list + +import ( + "cmp" + "context" + "fmt" + "slices" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// Status indicates whether a version is available locally or remotely +type Status string + +const ( + // Installed indicates that this version is installed on the local system + Installed Status = "installed" + // Available indicates that this version is available to download + Available Status = "available" +) + +// Result encapsulates a single item in the list of versions +type Result struct { + Version versions.Concrete + Platform versions.Platform + Status Status +} + +// List lists available versions, local and remote +func List(ctx context.Context, version versions.Spec, options ...Option) ([]Result, error) { + cfg := configure(options...) + env, err := env.New(cfg.envOpts...) + if err != nil { + return nil, err + } + + if err := env.Store.Initialize(ctx); err != nil { + return nil, err + } + + vs, err := env.Store.List(ctx, store.Filter{Version: version, Platform: cfg.platform}) + if err != nil { + return nil, fmt.Errorf("list installed versions: %w", err) + } + + results := make([]Result, 0, len(vs)) + for _, v := range vs { + results = append(results, Result{Version: v.Version, Platform: v.Platform, Status: Installed}) + } + + if cfg.localOnly { + return results, nil + } + + remoteVersions, err := env.Client.ListVersions(ctx) + if err != nil { + return nil, fmt.Errorf("list available versions: %w", err) + } + + for _, set := range remoteVersions { + if !version.Matches(set.Version) { + continue + } + slices.SortFunc(set.Platforms, func(a, b versions.PlatformItem) int { + return cmp.Or(cmp.Compare(a.OS, b.OS), cmp.Compare(a.Arch, b.Arch)) + }) + for _, plat := range set.Platforms { + if cfg.platform.Matches(plat.Platform) { + results = append(results, Result{ + Version: set.Version, + Platform: plat.Platform, + Status: Available, + }) + } + } + } + + return results, nil +} diff --git a/pkg/envtest/setup/list/list_test.go b/pkg/envtest/setup/list/list_test.go new file mode 100644 index 0000000000..9ab8eb84f0 --- /dev/null +++ b/pkg/envtest/setup/list/list_test.go @@ -0,0 +1,274 @@ +package list_test + +import ( + "cmp" + "context" + "regexp" + "slices" + "testing" + + "github.com/go-logr/logr" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/list" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/testhelpers" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var ( + testLog logr.Logger + ctx context.Context +) + +func TestEnv(t *testing.T) { + testLog = testhelpers.GetLogger() + ctx = logr.NewContext(context.Background(), testLog) + + RegisterFailHandler(Fail) + RunSpecs(t, "List Suite") +} + +var _ = Describe("List", func() { + var ( + envOpts []env.Option + ) + + JustBeforeEach(func() { + addr, shutdown, err := testhelpers.NewServer() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(shutdown) + + envOpts = append( + envOpts, + env.WithClient(&remote.GCSClient{ //nolint:staticcheck + Log: testLog.WithName("test-remote-client"), + Bucket: "kubebuilder-tools-test", + Server: addr, + Insecure: true, + }), + env.WithStore(testhelpers.NewMockStore()), + ) + }) + + Context("when downloads are disabled", func() { + JustBeforeEach(func() { + envOpts = append(envOpts, env.WithClient(nil)) // ensure tests fail if we try to contact remote + }) + + It("should include local contents sorted by version", func() { + result, err := list.List( + ctx, + versions.AnyVersion, + list.NoDownload(true), + list.WithPlatform("*", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + // build this list based on test data, to avoid having to change + // in two places if we add some more test cases + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + for _, p := range v.Platforms { + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want the results sorted in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + + It("should skip non-matching local contents", func() { + spec := versions.Spec{ + Selector: versions.PatchSelector{Major: 1, Minor: 16, Patch: versions.AnyPoint}, + } + result, err := list.List( + ctx, + spec, + list.NoDownload(true), + list.WithPlatform("linux", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + if !spec.Matches(v.Version) { + continue + } + for _, p := range v.Platforms { + if p.OS != "linux" { + continue + } + + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want the results sorted in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + }) + + Context("when downloads are enabled", func() { + It("should sort local & remote by version", func() { + result, err := list.List( + ctx, + versions.AnyVersion, + list.WithPlatform("*", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + // build this list based on test data, to avoid having to change + // in two places if we add some more test cases + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + for _, p := range v.Platforms { + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + rx := regexp.MustCompile(`^kubebuilder-tools-(\d+\.\d+\.\d+)-(\w+)-(\w+).tar.gz$`) + for _, v := range testhelpers.RemoteNames { + if m := rx.FindStringSubmatch(v); m != nil { + s, err := versions.FromExpr(m[1]) + Expect(err).NotTo(HaveOccurred()) + + expected = append(expected, list.Result{ + Version: *s.AsConcrete(), + Platform: versions.Platform{ + OS: m[2], + Arch: m[3], + }, + Status: list.Available, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want installed versions first, available after; + // compare in reverse order since "installed > "available" + cmp.Compare(b.Status, a.Status), + // then, sort in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + + It("should skip non-matching remote contents", func() { + result, err := list.List( + ctx, + versions.Spec{ + Selector: versions.PatchSelector{Major: 1, Minor: 16, Patch: versions.AnyPoint}, + }, + list.WithPlatform("*", "*"), + list.WithEnvOptions(envOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + + // build this list based on test data, to avoid having to change + // in two places if we add some more test cases + expected := make([]list.Result, 0) + for _, v := range testhelpers.LocalVersions { + // ignore versions not matching the filter in the options + if v.Version.Major != 1 || v.Version.Minor != 16 { + continue + } + for _, p := range v.Platforms { + expected = append(expected, list.Result{ + Version: v.Version, + Platform: p.Platform, + Status: list.Installed, + }) + } + } + rx := regexp.MustCompile(`^kubebuilder-tools-(\d+\.\d+\.\d+)-(\w+)-(\w+).tar.gz$`) + for _, v := range testhelpers.RemoteNames { + if m := rx.FindStringSubmatch(v); m != nil { + s, err := versions.FromExpr(m[1]) + Expect(err).NotTo(HaveOccurred()) + v := *s.AsConcrete() + // ignore versions not matching the filter in the options + if v.Major != 1 || v.Minor != 16 { + continue + } + expected = append(expected, list.Result{ + Version: v, + Platform: versions.Platform{ + OS: m[2], + Arch: m[3], + }, + Status: list.Available, + }) + } + } + // this sorting ensures the List method fulfils the contract of + // returning the most relevant items first + slices.SortFunc(expected, func(a, b list.Result) int { + return cmp.Or( + // we want installed versions first, available after; + // compare in reverse order since "installed > "available" + cmp.Compare(b.Status, a.Status), + // then, sort in descending order by version + cmp.Compare(b.Version.Major, a.Version.Major), + cmp.Compare(b.Version.Minor, a.Version.Minor), + cmp.Compare(b.Version.Patch, a.Version.Patch), + // ..and then in ascending order by platform + cmp.Compare(a.Platform.OS, b.Platform.OS), + cmp.Compare(a.Platform.Arch, b.Platform.Arch), + ) + }) + + Expect(result).To(HaveExactElements(expected)) + }) + }) +}) diff --git a/pkg/envtest/setup/remote/client.go b/pkg/envtest/setup/remote/client.go new file mode 100644 index 0000000000..3084d50d2b --- /dev/null +++ b/pkg/envtest/setup/remote/client.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 The Kubernetes Authors + +package remote + +import ( + "context" + "errors" + "io" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// ErrChecksumMismatch is returned when the checksum of the downloaded archive does not match the expected checksum. +var ErrChecksumMismatch = errors.New("checksum mismatch") + +// Client is an interface to get and list envtest binary archives. +type Client interface { + ListVersions(ctx context.Context) ([]versions.Set, error) + + GetVersion(ctx context.Context, version versions.Concrete, platform versions.PlatformItem, out io.Writer) error + + FetchSum(ctx context.Context, ver versions.Concrete, pl versions.Platform) (*versions.Hash, error) + + LatestVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (versions.Concrete, error) +} diff --git a/pkg/envtest/setup/remote/gcs_client.go b/pkg/envtest/setup/remote/gcs_client.go new file mode 100644 index 0000000000..4eda7f0498 --- /dev/null +++ b/pkg/envtest/setup/remote/gcs_client.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2021 The Kubernetes Authors + +package remote + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "sort" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// objectList is the parts we need of the GCS "list-objects-in-bucket" endpoint. +type objectList struct { + Items []bucketObject `json:"items"` + NextPageToken string `json:"nextPageToken"` +} + +// bucketObject is the parts we need of the GCS object metadata. +type bucketObject struct { + Name string `json:"name"` + Hash string `json:"md5Hash"` +} + +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. +// +// Deprecated: This client is deprecated and will be removed soon. +// The kubebuilder GCS bucket that we use with this client might be shutdown at any time, +// see: https://github.com/kubernetes/k8s.io/issues/2647. +type GCSClient struct { + // Bucket is the bucket to fetch from. + Bucket string + + // Server is the GCS-like storage server + Server string + + // Log allows us to log. + Log logr.Logger + + // Insecure uses http for testing + Insecure bool +} + +func (c *GCSClient) scheme() string { + if c.Insecure { + return "http" + } + return "https" +} + +// ListVersions lists all available tools versions in the given bucket, along +// with supported os/arch combos and the corresponding hash. +// +// The results are sorted with newer versions first. +func (c *GCSClient) ListVersions(ctx context.Context) ([]versions.Set, error) { + loc := &url.URL{ + Scheme: c.scheme(), + Host: c.Server, + Path: path.Join("/storage/v1/b/", c.Bucket, "o"), + } + query := make(url.Values) + + knownVersions := map[versions.Concrete][]versions.PlatformItem{} + for cont := true; cont; { + c.Log.V(1).Info("listing bucket to get versions", "bucket", c.Bucket) + + loc.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to construct request to list bucket items: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to perform request to list bucket items: %w", err) + } + + err = func() error { + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("unable list bucket items -- got status %q from GCS", resp.Status) + } + + var list objectList + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return fmt.Errorf("unable unmarshal bucket items list: %w", err) + } + + // continue listing if needed + cont = list.NextPageToken != "" + query.Set("pageToken", list.NextPageToken) + + for _, item := range list.Items { + ver, details := versions.ExtractWithPlatform(versions.ArchiveRE, item.Name) + if ver == nil { + c.Log.V(1).Info("skipping bucket object -- does not appear to be a versioned tools object", "name", item.Name) + continue + } + c.Log.V(1).Info("found version", "version", ver, "platform", details) + knownVersions[*ver] = append(knownVersions[*ver], versions.PlatformItem{ + Platform: details, + Hash: &versions.Hash{ + Type: versions.MD5HashType, + Encoding: versions.Base64HashEncoding, + Value: item.Hash, + }, + }) + } + + return nil + }() + if err != nil { + return nil, err + } + } + + res := make([]versions.Set, 0, len(knownVersions)) + for ver, details := range knownVersions { + res = append(res, versions.Set{Version: ver, Platforms: details}) + } + // sort in inverse order so that the newest one is first + sort.Slice(res, func(i, j int) bool { + first, second := res[i].Version, res[j].Version + return first.NewerThan(second) + }) + + return res, nil +} + +// GetVersion downloads the given concrete version for the given concrete platform, writing it to the out. +func (c *GCSClient) GetVersion(ctx context.Context, version versions.Concrete, platform versions.PlatformItem, out io.Writer) error { + itemName := platform.ArchiveName(true, version) + loc := &url.URL{ + Scheme: c.scheme(), + Host: c.Server, + Path: path.Join("/storage/v1/b/", c.Bucket, "o", itemName), + RawQuery: "alt=media", + } + + req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil) + if err != nil { + return fmt.Errorf("unable to construct request to fetch %s: %w", itemName, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("unable to fetch %s (%s): %w", itemName, req.URL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("unable fetch %s (%s) -- got status %q from GCS", itemName, req.URL, resp.Status) + } + + return readBody(resp, out, itemName, platform) +} + +// FetchSum fetches the checksum for the given concrete version & platform into +// the given platform item. +func (c *GCSClient) FetchSum(ctx context.Context, ver versions.Concrete, pl versions.Platform) (*versions.Hash, error) { + itemName := pl.ArchiveName(true, ver) + loc := &url.URL{ + Scheme: c.scheme(), + Host: c.Server, + Path: path.Join("/storage/v1/b/", c.Bucket, "o", itemName), + } + + req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to construct request to fetch metadata for %s: %w", itemName, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to fetch metadata for %s: %w", itemName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unable fetch metadata for %s -- got status %q from GCS", itemName, resp.Status) + } + + var item bucketObject + if err := json.NewDecoder(resp.Body).Decode(&item); err != nil { + return nil, fmt.Errorf("unable to unmarshal metadata for %s: %w", itemName, err) + } + + return &versions.Hash{ + Type: versions.MD5HashType, + Encoding: versions.Base64HashEncoding, + Value: item.Hash, + }, nil +} + +// LatestVersion returns the latest version of the tools that matches the given spec and platform. +func (c *GCSClient) LatestVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (versions.Concrete, error) { + vers, err := c.ListVersions(ctx) + if err != nil { + return versions.Concrete{}, fmt.Errorf("unable to list versions: %w", err) + } + + for _, set := range vers { + if !spec.Matches(set.Version) { + c.Log.V(1).Info("Skipping non-matching version", "version", set.Version) + continue + } + + for _, plat := range set.Platforms { + if platform.Matches(plat.Platform) { + return set.Version, nil + } + } + + c.Log.V(1).Info("Version is not supported on your platform, checking older ones", "version", set.Version, "platform", platform) + } + + return versions.Concrete{}, fmt.Errorf("no version found for platform %s", platform) +} diff --git a/pkg/envtest/setup/remote/http_client.go b/pkg/envtest/setup/remote/http_client.go new file mode 100644 index 0000000000..8ab265b303 --- /dev/null +++ b/pkg/envtest/setup/remote/http_client.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2021 The Kubernetes Authors + +package remote + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "sort" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" + "sigs.k8s.io/yaml" +) + +// DefaultIndexURL is the default index used in HTTPClient. +var DefaultIndexURL = "https://raw.githubusercontent.com/kubernetes-sigs/controller-tools/HEAD/envtest-releases.yaml" + +var _ Client = &HTTPClient{} + +// HTTPClient is a client for fetching versions of the envtest binary archives +// from an index via HTTP. +type HTTPClient struct { + // Log allows us to log. + Log logr.Logger + + // IndexURL is the URL of the index, defaults to DefaultIndexURL. + IndexURL string +} + +// Index represents an index of envtest binary archives. Example: +// +// releases: +// v1.28.0: +// envtest-v1.28.0-darwin-amd64.tar.gz: +// hash: +// selfLink: +type Index struct { + // Releases maps Kubernetes versions to Releases (envtest archives). + Releases map[string]Release `json:"releases"` +} + +// Release maps an archive name to an archive. +type Release map[string]Archive + +// Archive contains the self link to an archive and its hash. +type Archive struct { + Hash string `json:"hash"` + SelfLink string `json:"selfLink"` +} + +// ListVersions lists all available tools versions in the index, along +// with supported os/arch combos and the corresponding hash. +// +// The results are sorted with newer versions first. +func (c *HTTPClient) ListVersions(ctx context.Context) ([]versions.Set, error) { + index, err := c.getIndex(ctx) + if err != nil { + return nil, err + } + + knownVersions := map[versions.Concrete][]versions.PlatformItem{} + for _, releases := range index.Releases { + for archiveName, archive := range releases { + ver, details := versions.ExtractWithPlatform(versions.ArchiveRE, archiveName) + if ver == nil { + c.Log.V(1).Info("skipping archive -- does not appear to be a versioned tools archive", "name", archiveName) + continue + } + c.Log.V(1).Info("found version", "version", ver, "platform", details) + knownVersions[*ver] = append(knownVersions[*ver], versions.PlatformItem{ + Platform: details, + Hash: &versions.Hash{ + Type: versions.SHA512HashType, + Encoding: versions.HexHashEncoding, + Value: archive.Hash, + }, + }) + } + } + + res := make([]versions.Set, 0, len(knownVersions)) + for ver, details := range knownVersions { + res = append(res, versions.Set{Version: ver, Platforms: details}) + } + // sort in inverse order so that the newest one is first + sort.Slice(res, func(i, j int) bool { + first, second := res[i].Version, res[j].Version + return first.NewerThan(second) + }) + + return res, nil +} + +// GetVersion downloads the given concrete version for the given concrete platform, writing it to the out. +func (c *HTTPClient) GetVersion(ctx context.Context, version versions.Concrete, platform versions.PlatformItem, out io.Writer) error { + index, err := c.getIndex(ctx) + if err != nil { + return err + } + + var loc *url.URL + var name string + for _, releases := range index.Releases { + for archiveName, archive := range releases { + ver, details := versions.ExtractWithPlatform(versions.ArchiveRE, archiveName) + if ver == nil { + c.Log.V(1).Info("skipping archive -- does not appear to be a versioned tools archive", "name", archiveName) + continue + } + + if *ver == version && details.OS == platform.OS && details.Arch == platform.Arch { + loc, err = url.Parse(archive.SelfLink) + if err != nil { + return fmt.Errorf("error parsing selfLink %q, %w", loc, err) + } + name = archiveName + break + } + } + } + if name == "" { + return fmt.Errorf("unable to find archive for %s (%s,%s)", version, platform.OS, platform.Arch) + } + + req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil) + if err != nil { + return fmt.Errorf("unable to construct request to fetch %s: %w", name, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("unable to fetch %s (%s): %w", name, req.URL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("unable fetch %s (%s) -- got status %q", name, req.URL, resp.Status) + } + + return readBody(resp, out, name, platform) +} + +// FetchSum fetches the checksum for the given concrete version & platform +func (c *HTTPClient) FetchSum(ctx context.Context, version versions.Concrete, platform versions.Platform) (*versions.Hash, error) { + index, err := c.getIndex(ctx) + if err != nil { + return nil, err + } + + for _, releases := range index.Releases { + for archiveName, archive := range releases { + ver, details := versions.ExtractWithPlatform(versions.ArchiveRE, archiveName) + if ver == nil { + c.Log.V(1).Info("skipping archive -- does not appear to be a versioned tools archive", "name", archiveName) + continue + } + + if *ver == version && details.OS == platform.OS && details.Arch == platform.Arch { + return &versions.Hash{ + Type: versions.SHA512HashType, + Encoding: versions.HexHashEncoding, + Value: archive.Hash, + }, nil + } + } + } + + return nil, fmt.Errorf("unable to find archive for %s (%s,%s)", version, platform.OS, platform.Arch) +} + +// LatestVersion returns the latest version that matches the given spec and platform. +func (c *HTTPClient) LatestVersion(ctx context.Context, spec versions.Spec, platform versions.Platform) (versions.Concrete, error) { + vers, err := c.ListVersions(ctx) + if err != nil { + return versions.Concrete{}, fmt.Errorf("unable to list versions: %w", err) + } + + for _, set := range vers { + if !spec.Matches(set.Version) { + c.Log.V(1).Info("Skipping non-matching version", "version", set.Version) + continue + } + + for _, plat := range set.Platforms { + if platform.Matches(plat.Platform) { + return set.Version, nil + } + } + + c.Log.V(1).Info("Version is not supported on your platform, checking older ones", "version", set.Version, "platform", platform) + } + + return versions.Concrete{}, fmt.Errorf("no version found for platform %s", platform) +} + +func (c *HTTPClient) getIndex(ctx context.Context) (*Index, error) { + indexURL := c.IndexURL + if indexURL == "" { + indexURL = DefaultIndexURL + } + + loc, err := url.Parse(indexURL) + if err != nil { + return nil, fmt.Errorf("unable to parse index URL: %w", err) + } + + c.Log.V(1).Info("listing versions", "index", indexURL) + + req, err := http.NewRequestWithContext(ctx, "GET", loc.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to construct request to get index: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to perform request to get index: %w", err) + } + + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unable to get index -- got status %q", resp.Status) + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("unable to get index -- unable to read body %w", err) + } + + var index Index + if err := yaml.Unmarshal(responseBody, &index); err != nil { + return nil, fmt.Errorf("unable to unmarshal index: %w", err) + } + return &index, nil +} diff --git a/pkg/envtest/setup/remote/read_body.go b/pkg/envtest/setup/remote/read_body.go new file mode 100644 index 0000000000..77946f9b18 --- /dev/null +++ b/pkg/envtest/setup/remote/read_body.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 The Kubernetes Authors + +package remote + +import ( + //nolint:gosec // We're aware that md5 is a weak cryptographic primitive, but we don't have a choice here. + "crypto/md5" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "net/http" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +func readBody(resp *http.Response, out io.Writer, archiveName string, platform versions.PlatformItem) error { + if platform.Hash != nil { + // stream in chunks to do the checksum, don't load the whole thing into + // memory to avoid causing issues with big files. + buf := make([]byte, 32*1024) // 32KiB, same as io.Copy + var hasher hash.Hash + switch platform.Hash.Type { + case versions.SHA512HashType: + hasher = sha512.New() + case versions.MD5HashType: + hasher = md5.New() //nolint:gosec // We're aware that md5 is a weak cryptographic primitive, but we don't have a choice here. + default: + return fmt.Errorf("hash type %s not implemented", platform.Hash.Type) + } + for cont := true; cont; { + amt, err := resp.Body.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("unable read next chunk of %s: %w", archiveName, err) + } + if amt > 0 { + // checksum never returns errors according to docs + hasher.Write(buf[:amt]) + if _, err := out.Write(buf[:amt]); err != nil { + return fmt.Errorf("unable write next chunk of %s: %w", archiveName, err) + } + } + cont = amt > 0 && !errors.Is(err, io.EOF) + } + + var sum string + switch platform.Hash.Encoding { + case versions.Base64HashEncoding: + sum = base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + case versions.HexHashEncoding: + sum = hex.EncodeToString(hasher.Sum(nil)) + default: + return fmt.Errorf("hash encoding %s not implemented", platform.Hash.Encoding) + } + if 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) + } + return nil +} 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/pkg/envtest/setup/store/helpers.go b/pkg/envtest/setup/store/helpers.go new file mode 100644 index 0000000000..30902187e9 --- /dev/null +++ b/pkg/envtest/setup/store/helpers.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2021 The Kubernetes Authors + +package store + +import ( + "errors" + "os" + "path/filepath" + "runtime" +) + +// DefaultStoreDir returns the default location for the store. +// It's dependent on operating system: +// +// - Windows: %LocalAppData%\kubebuilder-envtest +// - OSX: ~/Library/Application Support/io.kubebuilder.envtest +// - Others: ${XDG_DATA_HOME:-~/.local/share}/kubebuilder-envtest +// +// Otherwise, it errors out. Note that these paths must not be relied upon +// manually. +func DefaultStoreDir() (string, error) { + var baseDir string + + // find the base data directory + switch runtime.GOOS { + case "windows": + baseDir = os.Getenv("LocalAppData") + if baseDir == "" { + return "", errors.New("%LocalAppData% is not defined") + } + case "darwin", "ios": + homeDir := os.Getenv("HOME") + if homeDir == "" { + return "", errors.New("$HOME is not defined") + } + baseDir = filepath.Join(homeDir, "Library/Application Support") + default: + baseDir = os.Getenv("XDG_DATA_HOME") + if baseDir == "" { + homeDir := os.Getenv("HOME") + if homeDir == "" { + return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined") + } + baseDir = filepath.Join(homeDir, ".local/share") + } + } + + // append our program-specific dir to it (OSX has a slightly different + // convention so try to follow that). + switch runtime.GOOS { + case "darwin", "ios": + return filepath.Join(baseDir, "io.kubebuilder.envtest"), nil + default: + return filepath.Join(baseDir, "kubebuilder-envtest"), nil + } +} diff --git a/pkg/envtest/setup/store/store.go b/pkg/envtest/setup/store/store.go new file mode 100644 index 0000000000..88acde4131 --- /dev/null +++ b/pkg/envtest/setup/store/store.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2021 The Kubernetes Authors + +package store + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/go-logr/logr" + "github.com/spf13/afero" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// TODO(directxman12): error messages don't show full path, which is gonna make +// things hard to debug + +// Item is a version-platform pair. +type Item struct { + Version versions.Concrete + Platform versions.Platform +} + +// dirName returns the directory name in the store for this item. +func (i Item) dirName() string { + return i.Platform.BaseName(i.Version) +} +func (i Item) String() string { + return fmt.Sprintf("%s (%s)", i.Version, i.Platform) +} + +// Filter is a version spec & platform selector (i.e. platform +// potentially with wilcards) to filter store items. +type Filter struct { + Version versions.Spec + Platform versions.Platform +} + +// Matches checks if this filter matches the given item. +func (f Filter) Matches(item Item) bool { + return f.Version.Matches(item.Version) && f.Platform.Matches(item.Platform) +} + +// Store knows how to list, load, store, and delete envtest tools. +type Store struct { + // Root is the root FS that the store stores in. You'll probably + // want to use a BasePathFS to scope it down to a particular directory. + // + // Note that if for some reason there are nested BasePathFSes, and they're + // interrupted by a non-BasePathFS, Path won't work properly. + Root afero.Fs +} + +// NewAt creates a new store on disk at the given path. +func NewAt(path string) *Store { + return &Store{ + Root: afero.NewBasePathFs(afero.NewOsFs(), path), + } +} + +// Initialize ensures that the store is all set up on disk, etc. +func (s *Store) Initialize(ctx context.Context) error { + log, err := logr.FromContext(ctx) + if err != nil { + return err + } + + log.V(1).Info("ensuring base binaries dir exists") + if err := s.unpackedBase().MkdirAll("", 0755); err != nil { + return fmt.Errorf("unable to make sure base binaries dir exists: %w", err) + } + return nil +} + +// Has checks if an item exists in the store. +func (s *Store) Has(item Item) (bool, error) { + path := s.unpackedPath(item.dirName()) + _, err := path.Stat("") + if err != nil && !errors.Is(err, afero.ErrFileNotFound) { + return false, fmt.Errorf("unable to check if version-platform dir exists: %w", err) + } + return err == nil, nil +} + +// List lists all items matching the given filter. +// +// Results are stored by version (newest first), and OS/arch (consistently, +// but no guaranteed ordering). +func (s *Store) List(ctx context.Context, matching Filter) ([]Item, error) { + var res []Item + if err := s.eachItem(ctx, matching, func(_ string, item Item) { + res = append(res, item) + }); err != nil { + return nil, fmt.Errorf("%w in store: %w", ErrUnableToList, err) + } + + sort.Slice(res, func(i, j int) bool { + if !res[i].Version.Matches(res[j].Version) { + return res[i].Version.NewerThan(res[j].Version) + } + return orderPlatforms(res[i].Platform, res[j].Platform) + }) + + return res, nil +} + +// Add adds this item to the store, with the given contents (a .tar.gz file). +func (s *Store) Add(ctx context.Context, item Item, contents io.Reader) (resErr error) { + log, err := logr.FromContext(ctx) + if err != nil { + return err + } + + itemName := item.dirName() + log = log.WithValues("version-platform", itemName) + itemPath := s.unpackedPath(itemName) + + // make sure to clean up if we hit an error + defer func() { + if resErr != nil { + // intentially ignore this because we can't really do anything + err := s.removeItem(itemPath) + if err != nil { + log.Error(err, "unable to clean up partially added version-platform pair after error") + } + } + }() + + log.V(1).Info("ensuring version-platform binaries dir exists and is empty & writable") + _, err = itemPath.Stat("") + if err != nil && !errors.Is(err, afero.ErrFileNotFound) { + return fmt.Errorf("unable to ensure version-platform binaries dir %s exists", itemName) + } + if err == nil { // exists + log.V(1).Info("cleaning up old version-platform binaries dir") + if err := s.removeItem(itemPath); err != nil { + return fmt.Errorf("unable to clean up existing version-platform binaries dir %s", itemName) + } + } + if err := itemPath.MkdirAll("", 0755); err != nil { + return fmt.Errorf("unable to make sure entry dir %s exists", itemName) + } + + log.V(1).Info("extracting archive") + gzStream, err := gzip.NewReader(contents) + if err != nil { + return fmt.Errorf("unable to start un-gz-ing entry archive") + } + tarReader := tar.NewReader(gzStream) + + var header *tar.Header + for header, err = tarReader.Next(); err == nil; header, err = tarReader.Next() { + if header.Typeflag != tar.TypeReg { // TODO(directxman12): support symlinks, etc? + log.V(1).Info("skipping non-regular-file entry in archive", "entry", header.Name) + continue + } + // just dump all files to the main path, ignoring the prefixed directory + // paths -- they're redundant. We also ignore bits for the most part (except for X), + // preferfing our own scheme. + targetPath := filepath.Base(header.Name) + log.V(1).Info("writing archive file to disk", "archive file", header.Name, "on-disk file", targetPath) + perms := 0555 & header.Mode // make sure we're at most r+x + binOut, err := itemPath.OpenFile(targetPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(perms)) + if err != nil { + return fmt.Errorf("unable to create file %s from archive to disk for version-platform pair %s", targetPath, itemName) + } + if err := func() error { // IIFE to get the defer properly in a loop + defer binOut.Close() + if _, err := io.Copy(binOut, tarReader); err != nil { //nolint:gosec + return fmt.Errorf("unable to write file %s from archive to disk for version-platform pair %s", targetPath, itemName) + } + return nil + }(); err != nil { + return err + } + } + 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") + + log.V(1).Info("switching version-platform directory to read-only") + if err := itemPath.Chmod("", 0555); err != nil { + // don't bail, this isn't fatal + log.Error(err, "unable to make version-platform directory read-only") + } + 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 +// of an error). +func (s *Store) Remove(ctx context.Context, matching Filter) ([]Item, error) { + log, err := logr.FromContext(ctx) + if err != nil { + return nil, err + } + + var removed []Item + var savedErr error + if err := s.eachItem(ctx, matching, func(name string, item Item) { + log.V(1).Info("Removing version-platform pair at path", "version-platform", item, "path", name) + + 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 = 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("%w to figure out what to delete: %w", ErrUnableToList, err) + } + if savedErr != nil { + return removed, savedErr + } + return removed, nil +} + +// Path returns an actual path that case be used to access this item. +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 + // root's base path. + // + // 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), "") +} + +// unpackedBase returns the directory in which item dirs lives. +func (s *Store) unpackedBase() afero.Fs { + return afero.NewBasePathFs(s.Root, "k8s") +} + +// unpackedPath returns the item dir with this name. +func (s *Store) unpackedPath(name string) afero.Fs { + return afero.NewBasePathFs(s.unpackedBase(), name) +} + +// eachItem iterates through the on-disk versions that match our version & platform selector, +// calling the callback for each. +func (s *Store) eachItem(ctx context.Context, filter Filter, cb func(name string, item Item)) error { + log, err := logr.FromContext(ctx) + if err != nil { + return err + } + + entries, err := afero.ReadDir(s.unpackedBase(), "") + if err != nil { + return fmt.Errorf("unable to list folders in store's unpacked directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + log.V(1).Info("skipping dir entry, not a folder", "entry", entry.Name()) + continue + } + ver, pl := versions.ExtractWithPlatform(versions.VersionPlatformRE, entry.Name()) + if ver == nil { + log.V(1).Info("skipping dir entry, not a version", "entry", entry.Name()) + continue + } + item := Item{Version: *ver, Platform: pl} + + if !filter.Matches(item) { + log.V(1).Info("skipping on disk version, does not match version and platform selectors", "platform", pl, "version", ver, "entry", entry.Name()) + continue + } + + cb(entry.Name(), item) + } + + return nil +} + +// removeItem removes the given item directory from disk. +func (s *Store) removeItem(itemDir afero.Fs) error { + if err := itemDir.Chmod("", 0755); err != nil { + // no point in trying to remove if we can't fix the permissions, bail here + return fmt.Errorf("unable to make version-platform dir writable: %w", err) + } + if err := itemDir.RemoveAll(""); err != nil && !errors.Is(err, afero.ErrFileNotFound) { + return fmt.Errorf("unable to remove version-platform dir: %w", err) + } + return nil +} + +// 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 +} diff --git a/pkg/envtest/setup/store/store_suite_test.go b/pkg/envtest/setup/store/store_suite_test.go new file mode 100644 index 0000000000..c2795a3227 --- /dev/null +++ b/pkg/envtest/setup/store/store_suite_test.go @@ -0,0 +1,51 @@ +/* +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 store_test + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/go-logr/zapr" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +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 logCtx() context.Context { + return logr.NewContext(context.Background(), testLog) +} + +func TestStore(t *testing.T) { + testLog = zapLogger() + RegisterFailHandler(Fail) + RunSpecs(t, "Store Suite") +} diff --git a/pkg/envtest/setup/store/store_test.go b/pkg/envtest/setup/store/store_test.go new file mode 100644 index 0000000000..a00bcbb2ec --- /dev/null +++ b/pkg/envtest/setup/store/store_test.go @@ -0,0 +1,280 @@ +/* +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 store_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/rand" + "io" + "io/fs" + "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" +) + +const ( + fakeStorePath = "/path/to/the/store" +) + +var _ = Describe("Store", func() { + var st *store.Store + BeforeEach(func() { + fs := afero.NewMemMapFs() + fakeStoreFiles(fs, fakeStorePath) + st = &store.Store{ + Root: afero.NewBasePathFs(fs, fakeStorePath), + } + }) + Describe("initialization", func() { + It("should ensure the repo root exists", func() { + // remove the old dir + Expect(st.Root.RemoveAll("")).To(Succeed(), "should be able to remove the store before trying to initialize") + + Expect(st.Initialize(logCtx())).To(Succeed(), "initialization should succeed") + Expect(st.Root.Stat("k8s")).NotTo(BeNil(), "store's binary dir should exist") + }) + + It("should be fine if the repo root already exists", func() { + Expect(st.Initialize(logCtx())).To(Succeed()) + }) + }) + Describe("listing items", func() { + It("should filter results by the given filter, sorted in version order (newest first)", func() { + sel, err := versions.FromExpr("<=1.16") + Expect(err).NotTo(HaveOccurred(), "should be able to construct <=1.16 selector") + Expect(st.List(logCtx(), store.Filter{ + Version: sel, + Platform: versions.Platform{OS: "*", Arch: "amd64"}, + })).To(Equal([]store.Item{ + {Version: ver(1, 16, 2), Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, + {Version: ver(1, 16, 1), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Version: ver(1, 16, 0), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Version: ver(1, 14, 26), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + })) + }) + It("should skip non-folders in the store", func() { + Expect(afero.WriteFile(st.Root, "k8s/2.3.6-linux-amd128", []byte{0x01}, fs.ModePerm)).To(Succeed(), "should be able to create a non-store file in the store directory") + Expect(st.List(logCtx(), store.Filter{ + Version: versions.AnyVersion, Platform: versions.Platform{OS: "linux", Arch: "amd128"}, + })).To(BeEmpty()) + }) + + It("should skip non-matching names in the store", func() { + Expect(st.Root.Mkdir("k8s/somedir-2.3.6-linux-amd128", fs.ModePerm)).To(Succeed(), "should be able to create a non-store file in the store directory") + Expect(st.List(logCtx(), store.Filter{ + Version: versions.AnyVersion, Platform: versions.Platform{OS: "linux", Arch: "amd128"}, + })).To(BeEmpty()) + }) + }) + + Describe("removing items", func() { + var res []store.Item + BeforeEach(func() { + sel, err := versions.FromExpr("<=1.16") + Expect(err).NotTo(HaveOccurred(), "should be able to construct <=1.16 selector") + res, err = st.Remove(logCtx(), store.Filter{ + Version: sel, + Platform: versions.Platform{OS: "*", Arch: "amd64"}, + }) + Expect(err).NotTo(HaveOccurred(), "should be able to remove <=1.16 & */amd64") + }) + It("should return all items removed", func() { + Expect(res).To(ConsistOf( + store.Item{Version: ver(1, 16, 2), Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, + store.Item{Version: ver(1, 16, 1), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + store.Item{Version: ver(1, 16, 0), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + store.Item{Version: ver(1, 14, 26), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + )) + }) + It("should remove all items matching the given filter from disk", func() { + Expect(afero.ReadDir(st.Root, "k8s")).NotTo(ContainElements( + WithTransform(fs.FileInfo.Name, Equal("1.16.2-ifonlysingularitywasstillathing-amd64")), + WithTransform(fs.FileInfo.Name, Equal("1.16.1-linux-amd64")), + WithTransform(fs.FileInfo.Name, Equal("1.16.0-linux-amd64")), + WithTransform(fs.FileInfo.Name, Equal("1.14.26-linux-amd64")), + )) + }) + + It("should leave items that don't match in place", func() { + Expect(afero.ReadDir(st.Root, "k8s")).To(ContainElements( + WithTransform(fs.FileInfo.Name, Equal("1.17.9-linux-amd64")), + WithTransform(fs.FileInfo.Name, Equal("1.16.2-linux-yourimagination")), + WithTransform(fs.FileInfo.Name, Equal("1.14.26-hyperwarp-pixiedust")), + )) + }) + }) + + Describe("adding items (GCS archives)", func() { + archiveName := "kubebuilder-tools-1.16.3-linux-amd64.tar.gz" + + It("should support .tar.gz input", func() { + Expect(st.Add(logCtx(), newItem, makeFakeArchive(archiveName, "kubebuilder/bin/"))).To(Succeed()) + Expect(st.Has(newItem)).To(BeTrue(), "should have the item after adding it") + }) + + It("should extract binaries from the given archive to a directly to the item's directory, regardless of path", func() { + Expect(st.Add(logCtx(), newItem, makeFakeArchive(archiveName, "kubebuilder/bin/"))).To(Succeed()) + + dirName := newItem.Platform.BaseName(newItem.Version) + Expect(afero.ReadFile(st.Root, filepath.Join("k8s", dirName, "some-file"))).To(HavePrefix(archiveName + "some-file")) + Expect(afero.ReadFile(st.Root, filepath.Join("k8s", dirName, "other-file"))).To(HavePrefix(archiveName + "other-file")) + }) + + It("should clean up any existing item directory before creating the new one", func() { + item := localVersions[0] + Expect(st.Add(logCtx(), item, makeFakeArchive(archiveName, "kubebuilder/bin/"))).To(Succeed()) + Expect(st.Root.Stat(filepath.Join("k8s", item.Platform.BaseName(item.Version)))).NotTo(BeNil(), "new files should exist") + }) + It("should clean up if it errors before finishing", func() { + item := localVersions[0] + Expect(st.Add(logCtx(), item, new(bytes.Buffer))).NotTo(Succeed(), "should fail to extract") + _, err := st.Root.Stat(filepath.Join("k8s", item.Platform.BaseName(item.Version))) + Expect(err).To(HaveOccurred(), "the binaries dir for the item should be gone") + + }) + }) + + Describe("adding items (controller-tools archives)", func() { + archiveName := "envtest-v1.16.3-linux-amd64.tar.gz" + + It("should support .tar.gz input", func() { + Expect(st.Add(logCtx(), newItem, makeFakeArchive(archiveName, "controller-tools/envtest/"))).To(Succeed()) + Expect(st.Has(newItem)).To(BeTrue(), "should have the item after adding it") + }) + + It("should extract binaries from the given archive to a directly to the item's directory, regardless of path", func() { + Expect(st.Add(logCtx(), newItem, makeFakeArchive(archiveName, "controller-tools/envtest/"))).To(Succeed()) + + dirName := newItem.Platform.BaseName(newItem.Version) + Expect(afero.ReadFile(st.Root, filepath.Join("k8s", dirName, "some-file"))).To(HavePrefix(archiveName + "some-file")) + Expect(afero.ReadFile(st.Root, filepath.Join("k8s", dirName, "other-file"))).To(HavePrefix(archiveName + "other-file")) + }) + + It("should clean up any existing item directory before creating the new one", func() { + item := localVersions[0] + Expect(st.Add(logCtx(), item, makeFakeArchive(archiveName, "controller-tools/envtest/"))).To(Succeed()) + Expect(st.Root.Stat(filepath.Join("k8s", item.Platform.BaseName(item.Version)))).NotTo(BeNil(), "new files should exist") + }) + It("should clean up if it errors before finishing", func() { + item := localVersions[0] + Expect(st.Add(logCtx(), item, new(bytes.Buffer))).NotTo(Succeed(), "should fail to extract") + _, err := st.Root.Stat(filepath.Join("k8s", item.Platform.BaseName(item.Version))) + Expect(err).To(HaveOccurred(), "the binaries dir for the item should be gone") + + }) + }) + + Describe("checking if items are present", func() { + It("should report that present directories are present", func() { + Expect(st.Has(localVersions[0])).To(BeTrue()) + }) + + It("should report that absent directories are absent", func() { + Expect(st.Has(newItem)).To(BeFalse()) + }) + }) + + Describe("getting the path", func() { + It("should return the absolute on-disk path of the given item", func() { + item := localVersions[0] + Expect(st.Path(item)).To(Equal(filepath.Join(fakeStorePath, "k8s", item.Platform.BaseName(item.Version)))) + }) + }) +}) + +var ( + // keep this sorted. + localVersions = []store.Item{ + {Version: ver(1, 17, 9), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Version: ver(1, 16, 2), Platform: versions.Platform{OS: "linux", Arch: "yourimagination"}}, + {Version: ver(1, 16, 2), Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, + {Version: ver(1, 16, 1), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Version: ver(1, 16, 0), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Version: ver(1, 14, 26), Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Version: ver(1, 14, 26), Platform: versions.Platform{OS: "hyperwarp", Arch: "pixiedust"}}, + } + + newItem = store.Item{ + Version: ver(1, 16, 3), + Platform: versions.Platform{OS: "linux", Arch: "amd64"}, + } +) + +func ver(major, minor, patch int) versions.Concrete { + return versions.Concrete{ + Major: major, + Minor: minor, + Patch: patch, + } +} + +func makeFakeArchive(magic, relativePath string) io.Reader { + out := new(bytes.Buffer) + gzipWriter := gzip.NewWriter(out) + tarWriter := tar.NewWriter(gzipWriter) + Expect(tarWriter.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: relativePath, // so we can ensure we skip non-files + Mode: 0777, + })).To(Succeed()) + for _, fileName := range []string{"some-file", "other-file"} { + // create fake file contents: magic+fileName+randomBytes() + var chunk [1024 * 48]byte // 1.5 times our chunk read size in GetVersion + copy(chunk[:], magic) + copy(chunk[len(magic):], fileName) + start := len(magic) + len(fileName) + if _, err := rand.Read(chunk[start:]); err != nil { + panic(err) + } + + // write to relativePath/fileName + err := tarWriter.WriteHeader(&tar.Header{ + Name: relativePath + fileName, + Size: int64(len(chunk[:])), + Mode: 0777, // so we can check that we fix this later + }) + if err != nil { + panic(err) + } + _, err = tarWriter.Write(chunk[:]) + if err != nil { + panic(err) + } + } + tarWriter.Close() + gzipWriter.Close() + + return out +} + +func fakeStoreFiles(fs afero.Fs, 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 _, item := range localVersions { + Expect(fs.Mkdir(filepath.Join(unpackedBase, item.Platform.BaseName(item.Version)), 0755)).To(Succeed()) + } +} diff --git a/pkg/envtest/setup/testhelpers/logging.go b/pkg/envtest/setup/testhelpers/logging.go new file mode 100644 index 0000000000..9d2fdb15c0 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/logging.go @@ -0,0 +1,21 @@ +package testhelpers + +import ( + "github.com/go-logr/logr" + "github.com/go-logr/zapr" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/onsi/ginkgo/v2" +) + +// GetLogger configures a logger that's suitable for testing +func GetLogger() logr.Logger { + testOut := zapcore.AddSync(ginkgo.GinkgoWriter) + enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) + + zapLog := zap.New(zapcore.NewCore(enc, testOut, zap.DebugLevel), + zap.ErrorOutput(testOut), zap.Development(), zap.AddStacktrace(zap.WarnLevel)) + + return zapr.NewLogger(zapLog) +} diff --git a/pkg/envtest/setup/testhelpers/package.go b/pkg/envtest/setup/testhelpers/package.go new file mode 100644 index 0000000000..cfeeda85e3 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/package.go @@ -0,0 +1,52 @@ +package testhelpers + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/md5" //nolint:gosec + "crypto/rand" + "encoding/base64" + "fmt" + "path/filepath" +) + +func ContentsFor(filename string) ([]byte, error) { //nolint:revive + var chunk [1024 * 48]byte // 1.5 times our chunk read size in GetVersion + copy(chunk[:], filename) + if _, err := rand.Read(chunk[len(filename):]); err != nil { + return nil, err + } + + out := new(bytes.Buffer) + gzipWriter := gzip.NewWriter(out) + + tarWriter := tar.NewWriter(gzipWriter) + + if err := tarWriter.WriteHeader(&tar.Header{ + Name: filepath.Join("kubebuilder/bin", filename), + Size: int64(len(chunk)), + Mode: 0777, // so we can check that we fix this later + }); err != nil { + return nil, fmt.Errorf("write tar header: %w", err) + } + if _, err := tarWriter.Write(chunk[:]); err != nil { + return nil, fmt.Errorf("write tar contents: %w", err) + } + + // can't defer these, because they need to happen before out.Bytes() below + tarWriter.Close() + gzipWriter.Close() + + return out.Bytes(), nil +} + +func verWith(name string, contents []byte) Item { + res := Item{ + Meta: BucketObject{Name: name}, + Contents: contents, + } + hash := md5.Sum(res.Contents) //nolint:gosec + res.Meta.Hash = base64.StdEncoding.EncodeToString(hash[:]) + return res +} diff --git a/pkg/envtest/setup/testhelpers/remote.go b/pkg/envtest/setup/testhelpers/remote.go new file mode 100644 index 0000000000..fc1a117ea7 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/remote.go @@ -0,0 +1,140 @@ +package testhelpers + +import ( + "errors" + "net/http" + + "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" +) + +// objectList is the parts we need of the GCS "list-objects-in-bucket" endpoint. +type objectList struct { + Items []BucketObject `json:"items"` +} + +// BucketObject is the parts we need of the GCS object metadata. +type BucketObject struct { + Name string `json:"name"` + Hash string `json:"md5Hash"` +} + +// Item represents a single object in the mock server. +type Item struct { + Meta BucketObject + Contents []byte +} + +var ( + // RemoteNames are all the package names that can be used on the mock server. + // Provide this, or a subset of it, to NewServerWithContents to get a mock server that knows about those packages. + RemoteNames = []string{ + "kubebuilder-tools-1.10-darwin-amd64.tar.gz", + "kubebuilder-tools-1.10-linux-amd64.tar.gz", + "kubebuilder-tools-1.10.1-darwin-amd64.tar.gz", + "kubebuilder-tools-1.10.1-linux-amd64.tar.gz", + "kubebuilder-tools-1.11.0-darwin-amd64.tar.gz", + "kubebuilder-tools-1.11.0-linux-amd64.tar.gz", + "kubebuilder-tools-1.11.1-potato-cherrypie.tar.gz", + "kubebuilder-tools-1.12.3-darwin-amd64.tar.gz", + "kubebuilder-tools-1.12.3-linux-amd64.tar.gz", + "kubebuilder-tools-1.13.1-darwin-amd64.tar.gz", + "kubebuilder-tools-1.13.1-linux-amd64.tar.gz", + "kubebuilder-tools-1.14.1-darwin-amd64.tar.gz", + "kubebuilder-tools-1.14.1-linux-amd64.tar.gz", + "kubebuilder-tools-1.15.5-darwin-amd64.tar.gz", + "kubebuilder-tools-1.15.5-linux-amd64.tar.gz", + "kubebuilder-tools-1.16.4-darwin-amd64.tar.gz", + "kubebuilder-tools-1.16.4-linux-amd64.tar.gz", + "kubebuilder-tools-1.17.9-darwin-amd64.tar.gz", + "kubebuilder-tools-1.17.9-linux-amd64.tar.gz", + "kubebuilder-tools-1.19.0-darwin-amd64.tar.gz", + "kubebuilder-tools-1.19.0-linux-amd64.tar.gz", + "kubebuilder-tools-1.19.2-darwin-amd64.tar.gz", + "kubebuilder-tools-1.19.2-linux-amd64.tar.gz", + "kubebuilder-tools-1.19.2-linux-arm64.tar.gz", + "kubebuilder-tools-1.19.2-linux-ppc64le.tar.gz", + "kubebuilder-tools-1.20.2-darwin-amd64.tar.gz", + "kubebuilder-tools-1.20.2-linux-amd64.tar.gz", + "kubebuilder-tools-1.20.2-linux-arm64.tar.gz", + "kubebuilder-tools-1.20.2-linux-ppc64le.tar.gz", + "kubebuilder-tools-1.9-darwin-amd64.tar.gz", + "kubebuilder-tools-1.9-linux-amd64.tar.gz", + "kubebuilder-tools-v1.19.2-darwin-amd64.tar.gz", + "kubebuilder-tools-v1.19.2-linux-amd64.tar.gz", + "kubebuilder-tools-v1.19.2-linux-arm64.tar.gz", + "kubebuilder-tools-v1.19.2-linux-ppc64le.tar.gz", + } + + contents map[string]Item +) + +func makeContents(names []string) ([]Item, error) { + res := make([]Item, len(names)) + if contents == nil { + contents = make(map[string]Item, len(RemoteNames)) + } + + var errs error + for i, name := range names { + if item, ok := contents[name]; ok { + res[i] = item + continue + } + + chunk, err := ContentsFor(name) + if err != nil { + errs = errors.Join(errs, err) + continue + } + + item := verWith(name, chunk) + contents[name] = item + res[i] = item + } + + if errs != nil { + return nil, errs + } + + return res, nil +} + +// NewServer spins up a mock server that knows about the provided packages. +// The package names should be a subset of RemoteNames. +// +// The returned shutdown function should be called at the end of the test +func NewServer(items ...Item) (addr string, shutdown func(), err error) { + if items == nil { + versions, err := makeContents(RemoteNames) + if err != nil { + return "", nil, err + } + items = versions + } + + server := ghttp.NewServer() + + list := objectList{Items: make([]BucketObject, len(items))} + for i, ver := range items { + ver := ver // copy to avoid capturing the iteration variable + list.Items[i] = ver.Meta + server.RouteToHandler("GET", "/storage/v1/b/kubebuilder-tools-test/o/"+ver.Meta.Name, func(resp http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("alt") == "media" { + resp.WriteHeader(http.StatusOK) + gomega.Expect(resp.Write(ver.Contents)).To(gomega.Equal(len(ver.Contents))) + } else { + ghttp.RespondWithJSONEncoded( + http.StatusOK, + ver.Meta, + )(resp, req) + } + }) + } + server.RouteToHandler("GET", "/storage/v1/b/kubebuilder-tools-test/o", ghttp.RespondWithJSONEncoded( + http.StatusOK, + list, + )) + + return server.Addr(), server.Close, nil +} diff --git a/pkg/envtest/setup/testhelpers/store.go b/pkg/envtest/setup/testhelpers/store.go new file mode 100644 index 0000000000..532f855cf4 --- /dev/null +++ b/pkg/envtest/setup/testhelpers/store.go @@ -0,0 +1,73 @@ +package testhelpers + +import ( + "path/filepath" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/spf13/afero" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var ( + // keep this sorted. + + // LocalVersions is a list of versions that the test helpers make available in the local store + LocalVersions = []versions.Set{ + {Version: versions.Concrete{Major: 1, Minor: 17, Patch: 9}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 2}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "yourimagination"}}, + {Platform: versions.Platform{OS: "ifonlysingularitywasstillathing", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 1}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + }}, + {Version: versions.Concrete{Major: 1, Minor: 14, Patch: 26}, Platforms: []versions.PlatformItem{ + {Platform: versions.Platform{OS: "linux", Arch: "amd64"}}, + {Platform: versions.Platform{OS: "hyperwarp", Arch: "pixiedust"}}, + }}, + } +) + +func initializeFakeStore(fs afero.Afero, dir string) { + ginkgo.By("making the unpacked directory") + unpackedBase := filepath.Join(dir, "k8s") + gomega.Expect(fs.Mkdir(unpackedBase, 0755)).To(gomega.Succeed()) + + ginkgo.By("making some fake (empty) versions") + for _, set := range LocalVersions { + for _, plat := range set.Platforms { + gomega.Expect(fs.Mkdir(filepath.Join(unpackedBase, plat.BaseName(set.Version)), 0755)).To(gomega.Succeed()) + } + } + + ginkgo.By("making some fake non-store paths") + gomega.Expect(fs.Mkdir(filepath.Join(dir, "missing", "binaries"), 0755)).To(gomega.Succeed()) + + gomega.Expect(fs.Mkdir(filepath.Join(dir, "wrong", "version"), 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong", "version", "kube-apiserver"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong", "version", "kubectl"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "wrong", "version", "etcd"), nil, 0755)).To(gomega.Succeed()) + + gomega.Expect(fs.Mkdir(filepath.Join(dir, "a", "good", "version"), 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "a", "good", "version", "kube-apiserver"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "a", "good", "version", "kubectl"), nil, 0755)).To(gomega.Succeed()) + gomega.Expect(fs.WriteFile(filepath.Join(dir, "a", "good", "version", "etcd"), nil, 0755)).To(gomega.Succeed()) + // TODO: put the right files +} + +// NewMockStore creates a new in-memory store, prepopulated with a set of packages +func NewMockStore() *store.Store { + fs := afero.NewMemMapFs() + storeRoot := ".test-binaries" + + initializeFakeStore(afero.Afero{Fs: fs}, storeRoot) + + return &store.Store{Root: afero.NewBasePathFs(fs, storeRoot)} +} diff --git a/pkg/envtest/setup/use/config.go b/pkg/envtest/setup/use/config.go new file mode 100644 index 0000000000..f5d78614f9 --- /dev/null +++ b/pkg/envtest/setup/use/config.go @@ -0,0 +1,71 @@ +package use + +import ( + "cmp" + "os" + "runtime" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +type config struct { + platform versions.Platform + assetPath string + noDownload bool + forceDownload bool + verifySum bool + + envOpts []env.Option +} + +// Option is a functional option for configuring the use workflow +type Option func(*config) + +// WithAssetsAt sets the path to the assets directory. +func WithAssetsAt(dir string) Option { + return func(c *config) { c.assetPath = dir } +} + +// WithAssetsFromEnv sets the path to the assets directory from the environment. +func WithAssetsFromEnv(useEnv bool) Option { + return func(c *config) { + if useEnv { + c.assetPath = cmp.Or(os.Getenv(env.KubebuilderAssetsEnvVar), c.assetPath) + } + } +} + +// ForceDownload forces the download of the specified version, even if it's already present. +func ForceDownload(force bool) Option { return func(c *config) { c.forceDownload = force } } + +// NoDownload ensures only local versions are considered +func NoDownload(noDownload bool) Option { return func(c *config) { c.noDownload = noDownload } } + +// WithPlatform sets the target OS and architecture for the download. +func WithPlatform(os string, arch string) Option { + return func(c *config) { c.platform = versions.Platform{OS: os, Arch: arch} } +} + +// WithEnvOptions provides options for the env.Env used by the workflow +func WithEnvOptions(opts ...env.Option) Option { + return func(c *config) { c.envOpts = append(c.envOpts, opts...) } +} + +// VerifySum turns on md5 verification of the downloaded package +func VerifySum(verify bool) Option { return func(c *config) { c.verifySum = verify } } + +func configure(options ...Option) *config { + cfg := &config{ + platform: versions.Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }, + } + + for _, opt := range options { + opt(cfg) + } + + return cfg +} diff --git a/pkg/envtest/setup/use/use.go b/pkg/envtest/setup/use/use.go new file mode 100644 index 0000000000..c7457f5142 --- /dev/null +++ b/pkg/envtest/setup/use/use.go @@ -0,0 +1,95 @@ +package use + +import ( + "context" + "errors" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/store" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +// Result summarizes the output of the Use workflow +type Result struct { + Version versions.Spec + Platform versions.Platform + Hash *versions.Hash + Path string +} + +// ErrNoMatchingVersion is returned when the spec matches no available +// version; available is defined both by versions being published at all, +// but also by other options such as NoDownload. +var ErrNoMatchingVersion = errors.New("no matching version found") + +// Use selects an appropriate version based on the user's spec, downloads it if needed, +// and returns the path to the binary asset directory. +func Use(ctx context.Context, version versions.Spec, options ...Option) (Result, error) { + cfg := configure(options...) + + env, err := env.New(cfg.envOpts...) + if err != nil { + return Result{}, err + } + + if cfg.assetPath != "" { + if v, ok := env.TryUseAssetsFromPath(ctx, version, cfg.assetPath); ok { + return Result{ + Version: v, + Platform: cfg.platform, + Path: cfg.assetPath, + }, nil + } + } + + selectedLocal, err := env.SelectLocalVersion(ctx, version, cfg.platform) + if err != nil { + return Result{}, err + } + + if cfg.noDownload { + if selectedLocal != (store.Item{}) { + return toResult(env, selectedLocal, nil), nil + } + + return Result{}, fmt.Errorf("%w: no local version matching %s found, but you specified NoDownload()", ErrNoMatchingVersion, version) + } + + if !cfg.forceDownload && !version.CheckLatest && selectedLocal != (store.Item{}) { + return toResult(env, selectedLocal, nil), nil + } + + selectedVersion, selectedPlatform, err := env.SelectRemoteVersion(ctx, version, cfg.platform) + if err != nil { + return Result{}, fmt.Errorf("%w: %w", ErrNoMatchingVersion, err) + } + + if selectedLocal != (store.Item{}) && !selectedVersion.NewerThan(selectedLocal.Version) { + return Result{ + Path: env.PathTo(&selectedLocal.Version, selectedLocal.Platform), + Version: versions.Spec{Selector: selectedLocal.Version}, + Platform: selectedLocal.Platform, + }, nil + } + + if err := env.FetchRemoteVersion(ctx, selectedVersion, selectedPlatform, cfg.verifySum); err != nil { + return Result{}, err + } + + return Result{ + Version: versions.Spec{Selector: *selectedVersion}, + Platform: selectedPlatform.Platform, + Path: env.PathTo(selectedVersion, selectedPlatform.Platform), + Hash: selectedPlatform.Hash, + }, nil +} + +func toResult(env *env.Env, item store.Item, hash *versions.Hash) Result { + return Result{ + Version: versions.Spec{Selector: item.Version}, + Platform: item.Platform, + Path: env.PathTo(&item.Version, item.Platform), + Hash: hash, + } +} diff --git a/pkg/envtest/setup/use/use_test.go b/pkg/envtest/setup/use/use_test.go new file mode 100644 index 0000000000..eb237ea0ba --- /dev/null +++ b/pkg/envtest/setup/use/use_test.go @@ -0,0 +1,344 @@ +package use_test + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/env" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/remote" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/testhelpers" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/use" + "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var ( + testLog logr.Logger + ctx context.Context +) + +func TestEnv(t *testing.T) { + testLog = testhelpers.GetLogger() + ctx = logr.NewContext(context.Background(), testLog) + + RegisterFailHandler(Fail) + RunSpecs(t, "Use Suite") +} + +var _ = Describe("Use", func() { + var ( + defaultEnvOpts []env.Option + version = versions.Spec{ + Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, + } + ) + JustBeforeEach(func() { + addr, shutdown, err := testhelpers.NewServer() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(shutdown) + + s := testhelpers.NewMockStore() + + defaultEnvOpts = []env.Option{ + env.WithClient(&remote.GCSClient{ //nolint:staticcheck + Log: testLog.WithName("test-remote-client"), + Bucket: "kubebuilder-tools-test", + Server: addr, + Insecure: true, + }), + env.WithStore(s), + env.WithFS(afero.NewIOFS(s.Root)), + } + }) + + Context("when useEnv is set", func() { + It("should fall back to normal behavior when the env is not set", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(version)) + Expect(result.Path).To(HaveSuffix("/1.16.0-linux-amd64"), "should fall back to a local version") + }) + + It("should fall back to normal behavior if binaries are missing", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt(".test-binaries/missing-binaries"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(version), "should fall back to a local version") + Expect(result.Path).To(HaveSuffix("/1.16.0-linux-amd64")) + }) + + It("should use the value of the env if it contains the right binaries", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt("a/good/version"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(versions.AnyVersion)) + Expect(result.Path).To(HaveSuffix("/good/version")) + }) + + It("should not try to check the version of the binaries", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt("wrong/version"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(versions.AnyVersion)) + Expect(result.Path).To(Equal("wrong/version")) + }) + + It("should not need to contact the network", func() { + result, err := use.Use( + ctx, + version, + use.WithAssetsFromEnv(true), + use.WithAssetsAt("a/good/version"), + use.WithPlatform("*", "*"), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Version).To(Equal(versions.AnyVersion)) + Expect(result.Path).To(HaveSuffix("/good/version")) + }) + }) + + Context("when downloads are disabled", func() { + It("should error if no matches are found locally", func() { + _, err := use.Use( + ctx, + versions.Spec{Selector: versions.Concrete{Major: 9001}}, + use.NoDownload(true), + use.WithPlatform("*", "*"), + // ensures tests panic if we try to connect to the network + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + It("should settle for the latest local match if latest is requested", func() { + result, err := use.Use( + ctx, + versions.Spec{ + CheckLatest: true, + Selector: versions.PatchSelector{ + Major: 1, + Minor: 16, + Patch: versions.AnyPoint, + }, + }, + use.WithPlatform("*", "*"), + use.NoDownload(true), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 2}})) + }) + }) + + Context("if latest is requested", func() { + It("should contact the network to see if there's anything newer", func() { + result, err := use.Use( + ctx, + versions.Spec{ + CheckLatest: true, + Selector: versions.PatchSelector{ + Major: 1, + Minor: 16, + Patch: versions.AnyPoint, + }, + }, + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 4}})) + }) + + It("should still use the latest local if the network doesn't have anything newer", func() { + result, err := use.Use( + ctx, + versions.Spec{ + CheckLatest: true, + Selector: versions.PatchSelector{ + Major: 1, + Minor: 14, + Patch: versions.AnyPoint, + }, + }, + use.WithPlatform("linux", "amd64"), + use.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 14, Patch: 26}})) + }) + }) + + It("should check for a local match first", func() { + result, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.TildeSelector{ + Concrete: versions.Concrete{Major: 1, Minor: 16, Patch: 0}, + }, + }, + use.WithPlatform("linux", "amd64"), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(nil))...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 16, Patch: 1}})) + }) + + It("should fall back to the network if no local matches are found", func() { + result, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.TildeSelector{ + Concrete: versions.Concrete{Major: 1, Minor: 19, Patch: 0}, + }, + }, + use.WithPlatform("linux", "amd64"), + use.WithEnvOptions(defaultEnvOpts...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 1, Minor: 19, Patch: 2}})) + }) + + It("should error out if no matches can be found anywhere", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{Major: 1, Minor: 13, Patch: 0}, + }, + use.WithPlatform("*", "*"), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + It("should skip local version matches with non-matching platform", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{Minor: 1, Major: 16, Patch: 2}, + }, + use.WithPlatform("linux", "amd64"), + use.NoDownload(true), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + It("should skip remote version matches with non-matching platform", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{Minor: 1, Major: 11, Patch: 1}, + }, + use.WithPlatform("linux", "amd64"), + use.NoDownload(true), + use.WithEnvOptions(defaultEnvOpts...), + ) + + Expect(err).To(MatchError(use.ErrNoMatchingVersion)) + }) + + Context("with an invalid checksum", func() { + var client remote.Client + BeforeEach(func() { + name := "kubebuilder-tools-86.75.309-linux-amd64.tar.gz" + contents, err := testhelpers.ContentsFor(name) + Expect(err).NotTo(HaveOccurred()) + + server, stop, err := testhelpers.NewServer(testhelpers.Item{ + Meta: testhelpers.BucketObject{ + Name: name, + Hash: "not the right one!", + }, + Contents: contents, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(stop) + + client = &remote.GCSClient{ //nolint:staticcheck + Bucket: "kubebuilder-tools-test", + Server: server, + Insecure: true, + Log: testLog.WithName("test-remote-client"), + } + }) + + When("validating the checksum", func() { + It("should fail with an appropriate error", func() { + _, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{ + Major: 86, + Minor: 75, + Patch: 309, + }, + }, + use.VerifySum(true), + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(client))...), + ) + + Expect(err).To(MatchError(remote.ErrChecksumMismatch)) + }) + }) + + When("not validating checksum", func() { + It("should return the version without error", func() { + result, err := use.Use( + ctx, + versions.Spec{ + Selector: versions.Concrete{ + Major: 86, + Minor: 75, + Patch: 309, + }, + }, + use.WithEnvOptions(append(defaultEnvOpts, env.WithClient(client))...), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Version).To(Equal(versions.Spec{Selector: versions.Concrete{Major: 86, Minor: 75, Patch: 309}})) + }) + }) + }) +}) diff --git a/pkg/envtest/setup/versions/misc_test.go b/pkg/envtest/setup/versions/misc_test.go new file mode 100644 index 0000000000..d486e2c8ce --- /dev/null +++ b/pkg/envtest/setup/versions/misc_test.go @@ -0,0 +1,155 @@ +/* +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 versions_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var _ = Describe("Concrete", func() { + It("should match the only same version", func() { + ver16 := Concrete{Major: 1, Minor: 16} + ver17 := Concrete{Major: 1, Minor: 17} + Expect(ver16.Matches(ver16)).To(BeTrue(), "should match the same version") + Expect(ver16.Matches(ver17)).To(BeFalse(), "should not match a different version") + }) + It("should serialize as X.Y.Z", func() { + Expect(Concrete{Major: 1, Minor: 16, Patch: 3}.String()).To(Equal("1.16.3")) + }) + Describe("when ordering relative to other versions", func() { + ver1163 := Concrete{Major: 1, Minor: 16, Patch: 3} + Specify("newer patch should be newer", func() { + Expect(ver1163.NewerThan(Concrete{Major: 1, Minor: 16})).To(BeTrue()) + }) + Specify("newer minor should be newer", func() { + Expect(ver1163.NewerThan(Concrete{Major: 1, Minor: 15, Patch: 3})).To(BeTrue()) + }) + Specify("newer major should be newer", func() { + Expect(ver1163.NewerThan(Concrete{Major: 0, Minor: 16, Patch: 3})).To(BeTrue()) + }) + }) +}) + +var _ = Describe("Platform", func() { + Specify("a concrete platform should match exactly itself", func() { + plat1 := Platform{OS: "linux", Arch: "amd64"} + plat2 := Platform{OS: "linux", Arch: "s390x"} + plat3 := Platform{OS: "windows", Arch: "amd64"} + Expect(plat1.Matches(plat1)).To(BeTrue(), "should match itself") + Expect(plat1.Matches(plat2)).To(BeFalse(), "should reject a different arch") + Expect(plat1.Matches(plat3)).To(BeFalse(), "should reject a different os") + }) + Specify("a wildcard arch should match any arch", func() { + sel := Platform{OS: "linux", Arch: "*"} + plat1 := Platform{OS: "linux", Arch: "amd64"} + plat2 := Platform{OS: "linux", Arch: "s390x"} + plat3 := Platform{OS: "windows", Arch: "amd64"} + Expect(sel.Matches(sel)).To(BeTrue(), "should match itself") + Expect(sel.Matches(plat1)).To(BeTrue(), "should match some arch with the same OS") + Expect(sel.Matches(plat2)).To(BeTrue(), "should match another arch with the same OS") + Expect(plat1.Matches(plat3)).To(BeFalse(), "should reject a different os") + }) + Specify("a wildcard os should match any os", func() { + sel := Platform{OS: "*", Arch: "amd64"} + plat1 := Platform{OS: "linux", Arch: "amd64"} + plat2 := Platform{OS: "windows", Arch: "amd64"} + plat3 := Platform{OS: "linux", Arch: "s390x"} + Expect(sel.Matches(sel)).To(BeTrue(), "should match itself") + Expect(sel.Matches(plat1)).To(BeTrue(), "should match some os with the same arch") + Expect(sel.Matches(plat2)).To(BeTrue(), "should match another os with the same arch") + Expect(plat1.Matches(plat3)).To(BeFalse(), "should reject a different arch") + }) + It("should report a wildcard OS as a wildcard platform", func() { + Expect(Platform{OS: "*", Arch: "amd64"}.IsWildcard()).To(BeTrue()) + }) + It("should report a wildcard arch as a wildcard platform", func() { + Expect(Platform{OS: "linux", Arch: "*"}.IsWildcard()).To(BeTrue()) + }) + It("should serialize as os/arch", func() { + Expect(Platform{OS: "linux", Arch: "amd64"}.String()).To(Equal("linux/amd64")) + }) + + Specify("knows how to produce a base store name", func() { + plat := Platform{OS: "linux", Arch: "amd64"} + ver := Concrete{Major: 1, Minor: 16, Patch: 3} + Expect(plat.BaseName(ver)).To(Equal("1.16.3-linux-amd64")) + }) + + Specify("knows how to produce an archive name", func() { + plat := Platform{OS: "linux", Arch: "amd64"} + ver := Concrete{Major: 1, Minor: 16, Patch: 3} + Expect(plat.ArchiveName(true, ver)).To(Equal("kubebuilder-tools-1.16.3-linux-amd64.tar.gz")) + Expect(plat.ArchiveName(false, ver)).To(Equal("envtest-v1.16.3-linux-amd64.tar.gz")) + }) + + Describe("parsing", func() { + Context("for version-platform names", func() { + It("should accept strings of the form x.y.z-os-arch", func() { + ver, plat := ExtractWithPlatform(VersionPlatformRE, "1.16.3-linux-amd64") + Expect(ver).To(Equal(&Concrete{Major: 1, Minor: 16, Patch: 3})) + Expect(plat).To(Equal(Platform{OS: "linux", Arch: "amd64"})) + }) + It("should reject nonsense strings", func() { + ver, _ := ExtractWithPlatform(VersionPlatformRE, "1.16-linux-amd64") + Expect(ver).To(BeNil()) + }) + }) + Context("for archive names (GCS)", func() { + It("should accept strings of the form kubebuilder-tools-x.y.z-os-arch.tar.gz", func() { + ver, plat := ExtractWithPlatform(ArchiveRE, "kubebuilder-tools-1.16.3-linux-amd64.tar.gz") + Expect(ver).To(Equal(&Concrete{Major: 1, Minor: 16, Patch: 3})) + Expect(plat).To(Equal(Platform{OS: "linux", Arch: "amd64"})) + }) + It("should reject nonsense strings", func() { + ver, _ := ExtractWithPlatform(ArchiveRE, "kubebuilder-tools-1.16.3-linux-amd64.tar.sum") + Expect(ver).To(BeNil()) + }) + }) + Context("for archive names (controller-tools)", func() { + It("should accept strings of the form envtest-vx.y.z-os-arch.tar.gz", func() { + ver, plat := ExtractWithPlatform(ArchiveRE, "envtest-v1.16.3-linux-amd64.tar.gz") + Expect(ver).To(Equal(&Concrete{Major: 1, Minor: 16, Patch: 3})) + Expect(plat).To(Equal(Platform{OS: "linux", Arch: "amd64"})) + }) + It("should reject nonsense strings", func() { + ver, _ := ExtractWithPlatform(ArchiveRE, "envtest-v1.16.3-linux-amd64.tar.sum") + Expect(ver).To(BeNil()) + }) + }) + }) +}) + +var _ = Describe("Spec helpers", func() { + Specify("can fill a spec with a concrete version", func() { + spec := Spec{Selector: AnySelector{}} // don't just use AnyVersion so we don't modify it + spec.MakeConcrete(Concrete{Major: 1, Minor: 16}) + Expect(spec.AsConcrete()).To(Equal(&Concrete{Major: 1, Minor: 16})) + }) + It("should serialize as the underlying selector with ! for check latest", func() { + spec, err := FromExpr("1.16.*!") + Expect(err).NotTo(HaveOccurred()) + Expect(spec.String()).To(Equal("1.16.*!")) + }) + It("should serialize as the underlying selector by itself if not check latest", func() { + spec, err := FromExpr("1.16.*") + Expect(err).NotTo(HaveOccurred()) + Expect(spec.String()).To(Equal("1.16.*")) + }) +}) diff --git a/pkg/envtest/setup/versions/parse.go b/pkg/envtest/setup/versions/parse.go new file mode 100644 index 0000000000..c89a98560d --- /dev/null +++ b/pkg/envtest/setup/versions/parse.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2021 The Kubernetes Authors + +package versions + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" +) + +var ( + // baseVersionRE is a semver-ish version -- either X.Y.Z, X.Y, or X.Y.{*|x}. + baseVersionRE = `(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*|x|\*))?` + // versionExprRe matches valid version input for FromExpr. + versionExprRE = regexp.MustCompile(`^(?P<|~|<=)?` + baseVersionRE + `(?P!)?$`) + + // ConcreteVersionRE matches a concrete version anywhere in the string. + ConcreteVersionRE = regexp.MustCompile(`(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)`) +) + +// FromExpr extracts a version from a string in the form of a semver version, +// where X, Y, and Z may also be wildcards ('*', 'x'), +// and pre-release names & numbers may also be wildcards. The prerelease section is slightly +// restricted to match what k8s does. +// The whole string is a version selector as follows: +// - X.Y.Z matches version X.Y.Z where x, y, and z are +// are ints >= 0, and Z may be '*' or 'x' +// - X.Y is equivalent to X.Y.* +// - ~X.Y.Z means >= X.Y.Z && < X.Y+1.0 +// - = comparisons, if we use + // wildcards with a selector we can just set them to zero. + if verInfo.Patch == AnyPoint { + verInfo.Patch = PointVersion(0) + } + baseVer := *verInfo.AsConcrete() + spec.Selector = TildeSelector{Concrete: baseVer} + default: + panic("unreachable: mismatch between FromExpr and its RE in selector") + } + + return spec, nil +} + +// PointVersionFromValidString extracts a point version +// from the corresponding string representation, which may +// be a number >= 0, or x|* (AnyPoint). +// +// Anything else will cause a panic (use this on strings +// extracted from regexes). +func PointVersionFromValidString(str string) PointVersion { + switch str { + case "*", "x": + return AnyPoint + default: + ver, err := strconv.Atoi(str) + if err != nil { + panic(err) + } + return PointVersion(ver) + } +} + +// PatchSelectorFromMatch constructs a simple selector according to the +// ParseExpr rules out of pre-validated sections. +// +// re must include name captures for major, minor, patch, prenum, and prelabel +// +// Any bad input may cause a panic. Use with when you got the parts from an RE match. +func PatchSelectorFromMatch(match []string, re *regexp.Regexp) PatchSelector { + // already parsed via RE, should be fine to ignore errors unless it's a + // *huge* number + major, err := strconv.Atoi(match[re.SubexpIndex("major")]) + if err != nil { + panic("invalid input passed as patch selector (invalid state)") + } + minor, err := strconv.Atoi(match[re.SubexpIndex("minor")]) + if err != nil { + panic("invalid input passed as patch selector (invalid state)") + } + + // patch is optional, means wilcard if left off + patch := AnyPoint + if patchRaw := match[re.SubexpIndex("patch")]; patchRaw != "" { + patch = PointVersionFromValidString(patchRaw) + } + return PatchSelector{ + Major: major, + Minor: minor, + 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/pkg/envtest/setup/versions/parse_test.go b/pkg/envtest/setup/versions/parse_test.go new file mode 100644 index 0000000000..1705637668 --- /dev/null +++ b/pkg/envtest/setup/versions/parse_test.go @@ -0,0 +1,95 @@ +/* +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 versions_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +func patchSel(x, y int, z PointVersion) PatchSelector { + return PatchSelector{Major: x, Minor: y, Patch: z} +} + +func patchSpec(x, y int, z PointVersion) Spec { + return Spec{Selector: patchSel(x, y, z)} +} + +func tildeSel(x, y, z int) TildeSelector { + return TildeSelector{ + Concrete: Concrete{ + Major: x, Minor: y, Patch: z, + }, + } +} + +func tildeSpec(x, y, z int) Spec { + return Spec{Selector: tildeSel(x, y, z)} +} +func ltSpec(x, y int, z PointVersion) Spec { + // this just keeps the table a bit shorter + return Spec{Selector: LessThanSelector{ + PatchSelector: patchSel(x, y, z), + }} +} +func lteSpec(x, y int, z PointVersion) Spec { + // this just keeps the table a bit shorter + return Spec{Selector: LessThanSelector{ + PatchSelector: patchSel(x, y, z), + OrEquals: true, + }} +} + +var _ = Describe("Parse", func() { + DescribeTable("it should support", + func(spec string, expected Spec) { + Expect(FromExpr(spec)).To(Equal(expected)) + }, + Entry("X.Y versions", "1.16", patchSpec(1, 16, AnyPoint)), + Entry("X.Y.Z versions", "1.16.3", patchSpec(1, 16, PointVersion(3))), + Entry("X.Y.x wildcard", "1.16.x", patchSpec(1, 16, AnyPoint)), + Entry("X.Y.* wildcard", "1.16.*", patchSpec(1, 16, AnyPoint)), + + Entry("~X.Y selector", "~1.16", tildeSpec(1, 16, 0)), + Entry("~X.Y.Z selector", "~1.16.3", tildeSpec(1, 16, 3)), + Entry("~X.Y.x selector", "~1.16.x", tildeSpec(1, 16, 0)), + Entry("~X.Y.* selector", "~1.16.*", tildeSpec(1, 16, 0)), + + Entry("\w+)-(?P\w+)` + // VersionPlatformRE matches concrete version-platform strings. + VersionPlatformRE = regexp.MustCompile(`^` + versionPlatformREBase + `$`) + // ArchiveRE matches concrete version-platform.tar.gz strings. + // The archives published to GCS by kubebuilder use the "kubebuilder-tools-" prefix (e.g. "kubebuilder-tools-1.30.0-darwin-amd64.tar.gz"). + // The archives published to GitHub releases by controller-tools use the "envtest-v" prefix (e.g. "envtest-v1.30.0-darwin-amd64.tar.gz"). + ArchiveRE = regexp.MustCompile(`^(kubebuilder-tools-|envtest-v)` + versionPlatformREBase + `\.tar\.gz$`) +) diff --git a/pkg/envtest/setup/versions/selectors_test.go b/pkg/envtest/setup/versions/selectors_test.go new file mode 100644 index 0000000000..046996d1a4 --- /dev/null +++ b/pkg/envtest/setup/versions/selectors_test.go @@ -0,0 +1,216 @@ +/* +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 versions_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "sigs.k8s.io/controller-runtime/pkg/envtest/setup/versions" +) + +var _ = Describe("Selectors", func() { + Describe("patch", func() { + var sel Selector + Context("with any patch", func() { + BeforeEach(func() { + var err error + sel, err = FromExpr("1.16.*") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should match any patch version with the same major & minor", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 3})).To(BeTrue(), "should match 1.16.3") + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 0})).To(BeTrue(), "should match 1.16.0") + }) + + It("should reject a different major", func() { + Expect(sel.Matches(Concrete{Major: 2, Minor: 16, Patch: 3})).To(BeFalse(), "should reject 2.16.3") + + }) + + It("should reject a different minor", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 17, Patch: 3})).To(BeFalse(), "should reject 1.17.3") + }) + + It("should serialize as X.Y.*", func() { + Expect(sel.String()).To(Equal("1.16.*")) + }) + + It("should not be concrete", func() { + Expect(sel.AsConcrete()).To(BeNil()) + }) + }) + + Context("with a specific patch", func() { + BeforeEach(func() { + var err error + sel, err = FromExpr("1.16.3") + Expect(err).NotTo(HaveOccurred()) + }) + It("should match exactly the major/minor/patch", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 3})).To(BeTrue(), "should match 1.16.3") + }) + + It("should reject a different major", func() { + Expect(sel.Matches(Concrete{Major: 2, Minor: 16, Patch: 3})).To(BeFalse(), "should reject 2.16.3") + + }) + + It("should reject a different minor", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 17, Patch: 3})).To(BeFalse(), "should reject 1.17.3") + + }) + + It("should reject a different patch", func() { + + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 4})).To(BeFalse(), "should reject 1.16.4") + }) + It("should serialize as X.Y.Z", func() { + Expect(sel.String()).To(Equal("1.16.3")) + }) + It("may be concrete", func() { + Expect(sel.AsConcrete()).To(Equal(&Concrete{Major: 1, Minor: 16, Patch: 3})) + }) + }) + + }) + + Describe("tilde", func() { + var sel Selector + BeforeEach(func() { + var err error + sel, err = FromExpr("~1.16.3") + Expect(err).NotTo(HaveOccurred()) + }) + It("should match exactly the major/minor/patch", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 3})).To(BeTrue(), "should match 1.16.3") + }) + + It("should match a patch greater than the given one, with the same major/minor", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 4})).To(BeTrue(), "should match 1.16.4") + }) + + It("should reject a patch less than the given one, with the same major/minor", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 2})).To(BeFalse(), "should reject 1.16.2") + + }) + + It("should reject a different major", func() { + Expect(sel.Matches(Concrete{Major: 2, Minor: 16, Patch: 3})).To(BeFalse(), "should reject 2.16.3") + + }) + + It("should reject a different minor", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 17, Patch: 3})).To(BeFalse(), "should reject 1.17.3") + + }) + + It("should treat ~X.Y.* as ~X.Y.Z", func() { + sel, err := FromExpr("~1.16.*") + Expect(err).NotTo(HaveOccurred()) + + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 0})).To(BeTrue(), "should match 1.16.0") + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 3})).To(BeTrue(), "should match 1.16.3") + Expect(sel.Matches(Concrete{Major: 1, Minor: 17, Patch: 0})).To(BeFalse(), "should reject 1.17.0") + }) + It("should serialize as ~X.Y.Z", func() { + Expect(sel.String()).To(Equal("~1.16.3")) + }) + It("should never be concrete", func() { + Expect(sel.AsConcrete()).To(BeNil()) + }) + }) + + Describe("less-than", func() { + var sel Selector + BeforeEach(func() { + var err error + sel, err = FromExpr("<1.16.3") + Expect(err).NotTo(HaveOccurred()) + }) + It("should reject the exact major/minor/patch", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 3})).To(BeFalse(), "should reject 1.16.3") + + }) + It("should reject greater patches", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 4})).To(BeFalse(), "should reject 1.16.4") + + }) + It("should reject greater majors", func() { + Expect(sel.Matches(Concrete{Major: 2, Minor: 16, Patch: 3})).To(BeFalse(), "should reject 2.16.3") + + }) + It("should reject greater minors", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 17, Patch: 3})).To(BeFalse(), "should reject 1.17.3") + + }) + + It("should accept lesser patches", func() { + + Expect(sel.Matches(Concrete{Major: 1, Minor: 16, Patch: 2})).To(BeTrue(), "should accept 1.16.2") + }) + It("should accept lesser majors", func() { + Expect(sel.Matches(Concrete{Major: 0, Minor: 16, Patch: 3})).To(BeTrue(), "should accept 0.16.3") + + }) + It("should accept lesser minors", func() { + Expect(sel.Matches(Concrete{Major: 1, Minor: 15, Patch: 3})).To(BeTrue(), "should accept 1.15.3") + + }) + It("should serialize as other.Major + } + if c.Minor != other.Minor { + return c.Minor > other.Minor + } + return c.Patch > other.Patch +} + +// Matches checks if this version is equal to the other one. +func (c Concrete) Matches(other Concrete) bool { + return c == other +} + +func (c Concrete) String() string { + return fmt.Sprintf("%d.%d.%d", c.Major, c.Minor, c.Patch) +} + +// PatchSelector selects a set of versions where the patch is a wildcard. +type PatchSelector struct { + Major, Minor int + Patch PointVersion +} + +func (s PatchSelector) String() string { + return fmt.Sprintf("%d.%d.%s", s.Major, s.Minor, s.Patch) +} + +// Matches checks if the given version matches this selector. +func (s PatchSelector) Matches(ver Concrete) bool { + return s.Major == ver.Major && s.Minor == ver.Minor && s.Patch.Matches(ver.Patch) +} + +// AsConcrete returns nil if there are wildcards in this selector, +// and the concrete version that this selects otherwise. +func (s PatchSelector) AsConcrete() *Concrete { + if s.Patch == AnyPoint { + return nil + } + + return &Concrete{ + Major: s.Major, + Minor: s.Minor, + Patch: int(s.Patch), // safe to cast, we've just checked wilcards above + } +} + +// TildeSelector selects [X.Y.Z, X.Y+1.0). +type TildeSelector struct { + Concrete +} + +// Matches checks if the given version matches this selector. +func (s TildeSelector) Matches(ver Concrete) bool { + if s.Concrete.Matches(ver) { + // easy, "exact" match + return true + } + return ver.Major == s.Major && ver.Minor == s.Minor && ver.Patch >= s.Patch +} +func (s TildeSelector) String() string { + return "~" + s.Concrete.String() +} + +// AsConcrete returns nil (this is never a concrete version). +func (s TildeSelector) AsConcrete() *Concrete { + return nil +} + +// LessThanSelector selects versions older than the given one +// (mainly useful for cleaning up). +type LessThanSelector struct { + PatchSelector + OrEquals bool +} + +// Matches checks if the given version matches this selector. +func (s LessThanSelector) Matches(ver Concrete) bool { + if s.Major != ver.Major { + return s.Major > ver.Major + } + if s.Minor != ver.Minor { + return s.Minor > ver.Minor + } + if !s.Patch.Matches(ver.Patch) { + // matches rules out a wildcard, so it's fine to compare as normal numbers + return int(s.Patch) > ver.Patch + } + return s.OrEquals +} +func (s LessThanSelector) String() string { + if s.OrEquals { + return "<=" + s.PatchSelector.String() + } + return "<" + s.PatchSelector.String() +} + +// AsConcrete returns nil (this is never a concrete version). +func (s LessThanSelector) AsConcrete() *Concrete { + return nil +} + +// AnySelector matches any version at all. +type AnySelector struct{} + +// Matches checks if the given version matches this selector. +func (AnySelector) Matches(_ Concrete) bool { return true } + +// AsConcrete returns nil (this is never a concrete version). +func (AnySelector) AsConcrete() *Concrete { return nil } +func (AnySelector) String() string { return "*" } + +// Selector selects some concrete version or range of versions. +type Selector interface { + // AsConcrete tries to return this selector as a concrete version. + // If the selector would only match a single version, it'll return + // that, otherwise it'll return nil. + AsConcrete() *Concrete + // Matches checks if this selector matches the given concrete version. + Matches(ver Concrete) bool + String() string +} + +// Spec matches some version or range of versions, and tells us how to deal with local and +// remote when selecting a version. +type Spec struct { + Selector + + // CheckLatest tells us to check the remote server for the latest + // version that matches our selector, instead of just relying on + // matching local versions. + CheckLatest bool +} + +// MakeConcrete replaces the contents of this spec with one that +// matches the given concrete version (without checking latest +// from the server). +func (s *Spec) MakeConcrete(ver Concrete) { + s.Selector = ver + 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 { + return s.Selector.AsConcrete() +} + +// Matches checks if the underlying selector matches the given version. +func (s Spec) Matches(ver Concrete) bool { + return s.Selector.Matches(ver) +} + +func (s Spec) String() string { + res := s.Selector.String() + if s.CheckLatest { + res += "!" + } + return res +} + +// PointVersion represents a wildcard (patch) version +// or concrete number. +type PointVersion int + +const ( + // AnyPoint matches any point version. + AnyPoint PointVersion = -1 +) + +// Matches checks if a point version is compatible +// with a concrete point version. +// Two point versions are compatible if they are +// a) both concrete +// b) one is a wildcard. +func (p PointVersion) Matches(other int) bool { + switch p { + case AnyPoint: + return true + default: + return int(p) == other + } +} +func (p PointVersion) String() string { + switch p { + case AnyPoint: + return "*" + default: + return strconv.Itoa(int(p)) + } +} + +var ( + // LatestVersion matches the most recent version on the remote server. + LatestVersion = Spec{ + Selector: AnySelector{}, + CheckLatest: true, + } + // AnyVersion matches any local or remote version. + AnyVersion = Spec{ + Selector: AnySelector{}, + } +) diff --git a/pkg/envtest/setup/versions/versions_suite_test.go b/pkg/envtest/setup/versions/versions_suite_test.go new file mode 100644 index 0000000000..db1fe76403 --- /dev/null +++ b/pkg/envtest/setup/versions/versions_suite_test.go @@ -0,0 +1,29 @@ +/* +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 versions_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestVersions(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Versions Suite") +}