diff --git a/augerctl/README.md b/augerctl/README.md new file mode 100644 index 0000000..9e8ea2b --- /dev/null +++ b/augerctl/README.md @@ -0,0 +1,136 @@ +# augerctl + +`augerctl` is a command line client for [Kubernetes][kubernetes] specific [etcd][etcd], +and as close as possible to [kubectl][kubectl]. +It can be used in scripts or for administrators to explore an etcd cluster. + +## Getting augerctl + +The latest release is not yet available as a binary on [Github][github-release], +the next release will be available. + +so that it can be built from source. + +``` bash +git clone https://github.com/etcd-io/auger +cd auger +go install ./augerctl +``` + +or + +``` bash +go install github.com/etcd-io/auger/augerctl@main +``` + +and the binary will be available in the path `$GOBIN` or `$GOPATH/bin` + +## Configuration + +### --endpoints ++ gRPC endpoints of etcd cluster ++ default: `"http://127.0.0.1:2379"` + +### --cert ++ path to the etcd client TLS cert file ++ default: none + +### --key ++ path to the etcd client TLS key file ++ default: none + +### --cacert ++ path to the etcd client TLS CA cert file ++ default: none + +### --user ++ username for authentication, provide username[:password] ++ default: none + +### --password ++ password for authentication, only available if --user has no password ++ default: none + +## Usage + +### Setting a resource + +TODO + +### Retrieving a resource + +List a single service with namespace `default` and name `kubernetes` + +``` bash +augerctl get services -n default kubernetes + +# Nearly equivalent +kubectl get services -n default kubernetes -o yaml +``` + +List a single resource of type `priorityclasses` and name `system-node-critical` without namespaced + +``` bash +augerctl get priorityclasses system-node-critical + +# Nearly equivalent +kubectl get priorityclasses system-node-critical -o yaml +``` + +List all leases with namespace `kube-system` + +``` bash +augerctl get leases -n kube-system + +# Nearly equivalent +kubectl get leases -n kube-system -o yaml +``` + +List a single resource of type `apiservices.apiregistration.k8s.io` and name `v1.apps` + +``` bash +augerctl get apiservices.apiregistration.k8s.io v1.apps + +# Nearly equivalent +kubectl get apiservices.apiregistration.k8s.io v1.apps -o yaml +``` + +List all resources + +``` bash +augerctl get + +# Nearly equivalent +kubectl get $(kubectl api-resources --verbs=list --output=name | paste -s -d, - ) -A -o yaml +``` + +### Deleting a resource + +TODO + +### Watching for changes + +TODO + +## Endpoint + +If the etcd cluster isn't available on `http://127.0.0.1:2379`, specify a `--endpoints` flag. + +## Project Details + +### Versioning + +augerctl uses [semantic versioning][semver]. +Releases will follow with the [Kubernetes][kubernetes] release cycle as possible (need API updates), +but the version numbers will be not. + +### License + +augerctl is under the Apache 2.0 license. See the [LICENSE][license] file for details. + +[kubernetes]: https://kubernetes.io/ +[kubectl]: https://kubectl.sigs.k8s.io/ +[etcd]: https://github.com/etcd-io/etcd +[github-release]: https://github.com/etcd-io/auger/releases/ +[license]: ../LICENSE +[semver]: http://semver.org/ diff --git a/augerctl/command/ctl.go b/augerctl/command/ctl.go new file mode 100644 index 0000000..adb522b --- /dev/null +++ b/augerctl/command/ctl.go @@ -0,0 +1,59 @@ +/* +Copyright 2024 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 command is a simple command line client for directly accessing data objects stored in etcd by Kubernetes. +package command + +import ( + "github.com/spf13/cobra" + + "go.etcd.io/etcd/client/pkg/v3/transport" +) + +type flagpole struct { + Endpoints []string + + InsecureSkipVerify bool + InsecureDiscovery bool + TLS transport.TLSInfo + + User string + Password string +} + +// NewCtlCommand returns a new cobra.Command for use ctl +func NewCtlCommand() *cobra.Command { + flags := &flagpole{} + + cmd := &cobra.Command{ + Use: "augerctl", + Short: "A simple command line client for directly accessing data objects stored in etcd by Kubernetes.", + } + cmd.PersistentFlags().StringSliceVar(&flags.Endpoints, "endpoints", []string{"127.0.0.1:2379"}, "gRPC endpoints of etcd cluster") + + cmd.PersistentFlags().BoolVar(&flags.InsecureDiscovery, "insecure-discovery", true, "accept insecure SRV records describing cluster endpoints") + cmd.PersistentFlags().BoolVar(&flags.InsecureSkipVerify, "insecure-skip-tls-verify", false, "skip server certificate verification") + cmd.PersistentFlags().StringVar(&flags.TLS.CertFile, "cert", "", "path to the etcd client TLS cert file") + cmd.PersistentFlags().StringVar(&flags.TLS.KeyFile, "key", "", "path to the etcd client TLS key file") + cmd.PersistentFlags().StringVar(&flags.TLS.TrustedCAFile, "cacert", "", "path to the etcd client TLS CA cert file") + cmd.PersistentFlags().StringVar(&flags.User, "user", "", "username for authentication, provide username[:password]") + cmd.PersistentFlags().StringVar(&flags.Password, "password", "", "password for authentication, only available if --user has no password") + + cmd.AddCommand( + newCtlGetCommand(flags), + ) + return cmd +} diff --git a/augerctl/command/get_command.go b/augerctl/command/get_command.go new file mode 100644 index 0000000..8ce1aba --- /dev/null +++ b/augerctl/command/get_command.go @@ -0,0 +1,137 @@ +/* +Copyright 2024 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 command + +import ( + "context" + "fmt" + "os" + + "github.com/etcd-io/auger/pkg/client" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type getFlagpole struct { + Namespace string + Output string + ChunkSize int64 + Prefix string +} + +var ( + getExample = ` + # List a single service with namespace "default" and name "kubernetes" + augerctl get services -n default kubernetes + # Nearly equivalent + kubectl get services -n default kubernetes -o yaml + + # List a single resource of type "priorityclasses" and name "system-node-critical" without namespaced + augerctl get priorityclasses system-node-critical + # Nearly equivalent + kubectl get priorityclasses system-node-critical -A -o yaml + + # List all leases with namespace "kube-system" + augerctl get leases -n kube-system + # Nearly equivalent + kubectl get leases -n kube-system -o yaml + + # List a single resource of type "apiservices.apiregistration.k8s.io" and name "v1.apps" + augerctl get apiservices.apiregistration.k8s.io v1.apps + # Nearly equivalent + kubectl get apiservices.apiregistration.k8s.io v1.apps -o yaml + + # List all resources + augerctl get + # Nearly equivalent + kubectl get $(kubectl api-resources --verbs=list --output=name | paste -s -d, - ) -A -o yaml +` +) + +func newCtlGetCommand(f *flagpole) *cobra.Command { + flags := &getFlagpole{} + + cmd := &cobra.Command{ + Args: cobra.RangeArgs(0, 2), + Use: "get [resource] [name]", + Short: "Gets the resource of Kubernetes in etcd", + Example: getExample, + RunE: func(cmd *cobra.Command, args []string) error { + etcdclient, err := clientFromCmd(f) + if err != nil { + return err + } + err = getCommand(cmd.Context(), etcdclient, flags, args) + + if err != nil { + return fmt.Errorf("%v: %w", args, err) + } + return nil + }, + } + + cmd.Flags().StringVarP(&flags.Output, "output", "o", "yaml", "output format. One of: (yaml).") + cmd.Flags().StringVarP(&flags.Namespace, "namespace", "n", "", "namespace of resource") + cmd.Flags().Int64Var(&flags.ChunkSize, "chunk-size", 500, "chunk size of the list pager") + cmd.Flags().StringVar(&flags.Prefix, "prefix", "/registry", "prefix to prepend to the resource") + + return cmd +} + +func getCommand(ctx context.Context, etcdclient client.Client, flags *getFlagpole, args []string) error { + var targetGr schema.GroupResource + var targetName string + var targetNamespace string + if len(args) != 0 { + // TODO: Support get information from CRD and scheme.Codecs + // Support short name + // Check for namespaced + + gr := schema.ParseGroupResource(args[0]) + if gr.Empty() { + return fmt.Errorf("invalid resource %q", args[0]) + } + targetGr = gr + targetNamespace = flags.Namespace + if len(args) >= 2 { + targetName = args[1] + } + } + + printer := NewPrinter(os.Stdout, flags.Output) + if printer == nil { + return fmt.Errorf("invalid output format: %q", flags.Output) + } + + opOpts := []client.OpOption{ + client.WithName(targetName, targetNamespace), + client.WithGroupResource(targetGr), + client.WithChunkSize(flags.ChunkSize), + client.WithResponse(printer.Print), + } + + // TODO: Support watch + + _, err := etcdclient.Get(ctx, flags.Prefix, + opOpts..., + ) + if err != nil { + return err + } + + return nil +} diff --git a/augerctl/command/global.go b/augerctl/command/global.go new file mode 100644 index 0000000..78a2ecb --- /dev/null +++ b/augerctl/command/global.go @@ -0,0 +1,84 @@ +/* +Copyright 2024 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 command + +import ( + "crypto/tls" + "fmt" + "strings" + + "github.com/etcd-io/auger/pkg/client" + + clientv3 "go.etcd.io/etcd/client/v3" +) + +func clientFromCmd(f *flagpole) (client.Client, error) { + cfg, err := clientConfigFromCmd(f) + if err != nil { + return nil, err + } + + cli, err := clientv3.New(cfg) + if err != nil { + return nil, err + } + + return client.NewClient(cli), nil +} + +func clientConfigFromCmd(f *flagpole) (clientv3.Config, error) { + cfg := clientv3.Config{ + Endpoints: f.Endpoints, + } + + if !f.TLS.Empty() { + clientTLS, err := f.TLS.ClientConfig() + if err != nil { + return clientv3.Config{}, err + } + cfg.TLS = clientTLS + } + + // if key/cert is not given but user wants secure connection, we + // should still setup an empty tls configuration for gRPC to setup + // secure connection. + if cfg.TLS == nil && !f.InsecureDiscovery { + cfg.TLS = &tls.Config{} + } + + // If the user wants to skip TLS verification then we should set + // the InsecureSkipVerify flag in tls configuration. + if cfg.TLS != nil && f.InsecureSkipVerify { + cfg.TLS.InsecureSkipVerify = true + } + + if f.User != "" { + if f.Password == "" { + splitted := strings.SplitN(f.User, ":", 2) + if len(splitted) < 2 { + return clientv3.Config{}, fmt.Errorf("password is missing") + } + cfg.Username = splitted[0] + cfg.Password = splitted[1] + } else { + cfg.Username = f.User + cfg.Password = f.Password + } + } + + return cfg, nil +} diff --git a/augerctl/command/printer.go b/augerctl/command/printer.go new file mode 100644 index 0000000..bf21e6d --- /dev/null +++ b/augerctl/command/printer.go @@ -0,0 +1,64 @@ +/* +Copyright 2024 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 command + +import ( + "errors" + "fmt" + "io" + + "github.com/etcd-io/auger/pkg/client" + "github.com/etcd-io/auger/pkg/encoding" + "github.com/etcd-io/auger/pkg/scheme" +) + +type Printer interface { + Print(kv *client.KeyValue) error +} + +func NewPrinter(w io.Writer, printerType string) Printer { + switch printerType { + case "yaml": + return &yamlPrinter{w: w} + } + return nil +} + +func formatResponse(w io.Writer, outMediaType string, kv *client.KeyValue) error { + value := kv.Value + inMediaType, _, err := encoding.DetectAndExtract(value) + if err != nil { + _, err0 := fmt.Fprintf(w, "---\n# %s | raw | %v\n# %s\n", kv.Key, err, value) + if err0 != nil { + return errors.Join(err, err0) + } + return nil + } + data, _, err := encoding.Convert(scheme.Codecs, inMediaType, outMediaType, value) + if err != nil { + _, err0 := fmt.Fprintf(w, "---\n# %s | raw | %v\n# %s\n", kv.Key, err, value) + if err0 != nil { + return errors.Join(err, err0) + } + return nil + } + _, err = fmt.Fprintf(w, "---\n# %s | %s\n%s\n", kv.Key, inMediaType, data) + if err != nil { + return err + } + return nil +} diff --git a/augerctl/command/printer_yaml.go b/augerctl/command/printer_yaml.go new file mode 100644 index 0000000..c7abb98 --- /dev/null +++ b/augerctl/command/printer_yaml.go @@ -0,0 +1,32 @@ +/* +Copyright 2024 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 command + +import ( + "io" + + "github.com/etcd-io/auger/pkg/client" + "github.com/etcd-io/auger/pkg/encoding" +) + +type yamlPrinter struct { + w io.Writer +} + +func (p *yamlPrinter) Print(kv *client.KeyValue) error { + return formatResponse(p.w, encoding.YamlMediaType, kv) +} diff --git a/augerctl/main.go b/augerctl/main.go new file mode 100644 index 0000000..19a7717 --- /dev/null +++ b/augerctl/main.go @@ -0,0 +1,31 @@ +/* +Copyright 2024 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 main + +import ( + "fmt" + "os" + + "github.com/etcd-io/auger/augerctl/command" +) + +func main() { + if err := command.NewCtlCommand().Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 36b8ab7..0647444 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/spf13/cobra v1.8.1 go.etcd.io/bbolt v1.3.10 go.etcd.io/etcd/api/v3 v3.5.15 + go.etcd.io/etcd/client/pkg/v3 v3.5.15 + go.etcd.io/etcd/client/v3 v3.5.15 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.30.3 k8s.io/apimachinery v0.30.3 @@ -16,6 +18,8 @@ require ( ) require ( + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -26,9 +30,16 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.9.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.17.0 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/text v0.17.0 // indirect + google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // 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/grpc v1.59.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index e410747..68f6122 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,14 @@ +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -31,6 +36,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -42,6 +50,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -50,6 +59,16 @@ go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= go.etcd.io/etcd/api/v3 v3.5.15 h1:3KpLJir1ZEBrYuV2v+Twaa/e2MdDCEZ/70H+lzEiwsk= go.etcd.io/etcd/api/v3 v3.5.15/go.mod h1:N9EhGzXq58WuMllgH9ZvnEr7SI9pS0k0+DHZezGp7jM= +go.etcd.io/etcd/client/pkg/v3 v3.5.15 h1:fo0HpWz/KlHGMCC+YejpiCmyWDEuIpnTDzpJLB5fWlA= +go.etcd.io/etcd/client/pkg/v3 v3.5.15/go.mod h1:mXDI4NAOwEiszrHCb0aqfAYNCrZP4e9hRca3d1YK8EU= +go.etcd.io/etcd/client/v3 v3.5.15 h1:23M0eY4Fd/inNv1ZfU3AxrbbOdW79r9V9Rl62Nm6ip4= +go.etcd.io/etcd/client/v3 v3.5.15/go.mod h1:CLSJxrYjvLtHsrPKsy7LmZEE+DK2ktfd2bN4RhBMwlU= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -83,6 +102,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/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.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -93,6 +120,8 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ= diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..1cc53d4 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,117 @@ +/* +Copyright 2024 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 client + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime/schema" + + "go.etcd.io/etcd/api/v3/mvccpb" + + clientv3 "go.etcd.io/etcd/client/v3" +) + +// Client is an interface that defines the operations that can be performed on an etcd client. +type Client interface { + // Get is a method that retrieves a key-value pair from the etcd server. + // It returns the revision of the key-value pair + Get(ctx context.Context, prefix string, opOpts ...OpOption) (rev int64, err error) +} + +// client is the etcd client. +type client struct { + client *clientv3.Client +} + +// NewClient creates a new etcd client. +func NewClient(cli *clientv3.Client) Client { + return &client{ + client: cli, + } +} + +// op is the option for the operation. +type op struct { + gr schema.GroupResource + name string + namespace string + // this is required if it is a query operation + response func(kv *KeyValue) error + // max number of results per clientv3 request. + chunkSize int64 + revision int64 +} + +// OpOption is the option for the operation. +type OpOption func(*op) + +// WithGroupResource sets the schema.GroupResource for the target. +func WithGroupResource(gr schema.GroupResource) OpOption { + return func(o *op) { + o.gr = gr + } +} + +// WithName sets the name and namespace for the target. +func WithName(name, namespace string) OpOption { + return func(o *op) { + o.name = name + o.namespace = namespace + } +} + +// WithResponse sets the response callback for the target. +func WithResponse(response func(kv *KeyValue) error) OpOption { + return func(o *op) { + o.response = response + } +} + +// WithChunkSize sets the max number of results per clientv3 request. +func WithChunkSize(chunkSize int64) OpOption { + return func(o *op) { + o.chunkSize = chunkSize + } +} + +func opOption(opts []OpOption) op { + var opt op + for _, o := range opts { + o(&opt) + } + return opt +} + +// KeyValue is the key-value pair. +type KeyValue struct { + Key []byte + Value []byte +} + +func iterateList(kvs []*mvccpb.KeyValue, callback func(kv *KeyValue) error) error { + for _, kv := range kvs { + err := callback(&KeyValue{ + Key: kv.Key, + Value: kv.Value, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/client/client_get.go b/pkg/client/client_get.go new file mode 100644 index 0000000..5ef950e --- /dev/null +++ b/pkg/client/client_get.go @@ -0,0 +1,96 @@ +/* +Copyright 2024 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 client + +import ( + "context" + "fmt" + + clientv3 "go.etcd.io/etcd/client/v3" +) + +func (c *client) Get(ctx context.Context, prefix string, opOpts ...OpOption) (rev int64, err error) { + if prefix == "" { + return 0, fmt.Errorf("prefix is required") + } + + opt := opOption(opOpts) + if opt.response == nil { + return 0, fmt.Errorf("response is required") + } + + path, single, err := getPrefix(prefix, opt.gr, opt.name, opt.namespace) + if err != nil { + return 0, err + } + + opts := make([]clientv3.OpOption, 0, 3) + + // specify whether it is a key or a prefix + if !single { + opts = append(opts, clientv3.WithPrefix()) + } + + // specify an explicit revision and always use it + if opt.revision != 0 { + rev = opt.revision + opts = append(opts, clientv3.WithRev(rev)) + } + + // it is a key or it is not paging + if single || opt.chunkSize == 0 { + resp, err := c.client.Get(ctx, path, opts...) + if err != nil { + return 0, err + } + + err = iterateList(resp.Kvs, opt.response) + if err != nil { + return 0, err + } + return resp.Header.Revision, nil + } + + // paging for content + opts = append(opts, clientv3.WithLimit(opt.chunkSize)) + for key := path; ; { + resp, err := c.client.Get(ctx, key, opts...) + if err != nil { + return 0, err + } + + err = iterateList(resp.Kvs, opt.response) + if err != nil { + return 0, err + } + + // if revision is not set, it is set to the revision of the first response. + if rev == 0 { + rev = resp.Header.Revision + opts = append(opts, clientv3.WithRev(resp.Header.Revision)) + } + + if !resp.More { + break + } + + // move to next key + key = string(append(resp.Kvs[len(resp.Kvs)-1].Key, 0)) + } + + return rev, nil +} diff --git a/pkg/client/util.go b/pkg/client/util.go new file mode 100644 index 0000000..16a2acf --- /dev/null +++ b/pkg/client/util.go @@ -0,0 +1,104 @@ +/* +Copyright 2024 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 client + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// specialDefaultResourcePrefixes are prefixes compiled into Kubernetes. +// see https://github.com/kubernetes/kubernetes/blob/a2106b5f73fe9352f7bc0520788855d57fc301e1/pkg/kubeapiserver/default_storage_factory_builder.go#L42-L50 +var specialDefaultResourcePrefixes = map[schema.GroupResource]string{ + {Group: "", Resource: "replicationcontrollers"}: "controllers", + {Group: "", Resource: "endpoints"}: "services/endpoints", + {Group: "", Resource: "nodes"}: "minions", + {Group: "", Resource: "services"}: "services/specs", + {Group: "extensions", Resource: "ingresses"}: "ingress", + {Group: "networking.k8s.io", Resource: "ingresses"}: "ingress", +} + +var specialDefaultMediaTypes = map[string]struct{}{ + "apiextensions.k8s.io": {}, + "apiregistration.k8s.io": {}, +} + +// prefixFromGR returns the prefix of the given GroupResource. +func prefixFromGR(gr schema.GroupResource) (string, error) { + if gr.Resource == "" { + return "", fmt.Errorf("resource is empty") + } + + if prefix, ok := specialDefaultResourcePrefixes[gr]; ok { + return prefix, nil + } + + if _, ok := specialDefaultMediaTypes[gr.Group]; ok { + return gr.Group + "/" + gr.Resource, nil + } + + if gr.Group == "" { + return gr.Resource, nil + } + + if !strings.Contains(gr.Group, ".") { + return gr.Resource, nil + } + + // TODO: This can cause errors if custom resources use this group. + if strings.HasSuffix(gr.Group, ".k8s.io") { + return gr.Resource, nil + } + + // custom resources + return gr.Group + "/" + gr.Resource, nil +} + +// getPrefix returns path and wantSingle +// the path means the key based on schema.GroupResource and the resource name and namespace. +// the single means that the name is specified, this is a single resource +func getPrefix(prefix string, gr schema.GroupResource, name, namespace string) (path string, single bool, err error) { + var arr [4]string + s := arr[:0] + s = append(s, prefix) + + if gr.Empty() { + if namespace != "" || name != "" { + return "", false, fmt.Errorf("namespace and name must be omitted if there is no GroupResource") + } + } else { + p, err := prefixFromGR(gr) + if err != nil { + return "", false, err + } + s = append(s, p) + if namespace != "" { + s = append(s, namespace) + } + if name != "" { + s = append(s, name) + single = true + } + } + + if !single { + s = append(s, "") + } + return strings.Join(s, "/"), single, nil +} diff --git a/pkg/client/util_test.go b/pkg/client/util_test.go new file mode 100644 index 0000000..ed72ecf --- /dev/null +++ b/pkg/client/util_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2024 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 client + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestPrefixFromGR(t *testing.T) { + type args struct { + gr schema.GroupResource + } + tests := []struct { + name string + args args + wantPrefix string + wantErr bool + }{ + { + name: "pod", + args: args{ + gr: schema.GroupResource{ + Group: "", + Resource: "pods", + }, + }, + wantPrefix: "pods", + wantErr: false, + }, + { + name: "deployment", + args: args{ + gr: schema.GroupResource{ + Group: "apps", + Resource: "deployments", + }, + }, + wantPrefix: "deployments", + wantErr: false, + }, + { + name: "service", + args: args{ + gr: schema.GroupResource{ + Group: "", + Resource: "services", + }, + }, + wantPrefix: "services/specs", + wantErr: false, + }, + { + name: "ingress", + args: args{ + gr: schema.GroupResource{ + Group: "networking.k8s.io", + Resource: "ingresses", + }, + }, + wantPrefix: "ingress", + }, + { + name: "apiextensions.k8s.io", + args: args{ + gr: schema.GroupResource{ + Group: "apiextensions.k8s.io", + Resource: "customresourcedefinitions", + }, + }, + wantPrefix: "apiextensions.k8s.io/customresourcedefinitions", + }, + { + name: "scheduling.k8s.io", + args: args{ + gr: schema.GroupResource{ + Group: "scheduling.k8s.io", + Resource: "priorityclasses", + }, + }, + wantPrefix: "priorityclasses", + }, + { + name: "x-k8s.io", + args: args{ + gr: schema.GroupResource{ + Group: "auger.x-k8s.io", + Resource: "foo", + }, + }, + wantPrefix: "auger.x-k8s.io/foo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPrefix, err := prefixFromGR(tt.args.gr) + if (err != nil) != tt.wantErr { + t.Errorf("prefixFromGR() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotPrefix != tt.wantPrefix { + t.Errorf("prefixFromGR() gotPrefix = %v, wantPath %v", gotPrefix, tt.wantPrefix) + } + }) + } +} + +func TestGetPrefix(t *testing.T) { + type args struct { + prefix string + gr schema.GroupResource + name string + namespace string + } + tests := []struct { + name string + args args + wantPath string + wantSingle bool + wantErr bool + }{ + { + name: "all", + args: args{ + prefix: "/registry", + }, + wantPath: "/registry/", + wantSingle: false, + }, + { + name: "wantSingle pod", + args: args{ + prefix: "/registry", + gr: schema.GroupResource{ + Group: "", + Resource: "pods", + }, + name: "pod", + namespace: "default", + }, + wantPath: "/registry/pods/default/pod", + wantSingle: true, + }, + { + name: "pods", + args: args{ + prefix: "/registry", + gr: schema.GroupResource{ + Group: "", + Resource: "pods", + }, + namespace: "default", + }, + wantPath: "/registry/pods/default/", + wantSingle: false, + }, + { + name: "wantSingle node", + args: args{ + prefix: "/registry", + gr: schema.GroupResource{ + Group: "", + Resource: "nodes", + }, + name: "node", + }, + wantPath: "/registry/minions/node", + wantSingle: true, + }, + { + name: "nodes", + args: args{ + prefix: "/registry", + gr: schema.GroupResource{ + Group: "", + Resource: "nodes", + }, + }, + wantPath: "/registry/minions/", + wantSingle: false, + }, + { + name: "cr", + args: args{ + prefix: "/registry", + gr: schema.GroupResource{ + Group: "auger.x-k8s.io", + Resource: "foo", + }, + }, + wantPath: "/registry/auger.x-k8s.io/foo/", + wantSingle: false, + }, + { + name: "apiservices v1.apps", + args: args{ + prefix: "/registry", + gr: schema.GroupResource{ + Group: "apiregistration.k8s.io", + Resource: "apiservices", + }, + name: "v1.apps", + }, + wantPath: "/registry/apiregistration.k8s.io/apiservices/v1.apps", + wantSingle: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPath, gotSingle, err := getPrefix(tt.args.prefix, tt.args.gr, tt.args.name, tt.args.namespace) + if (err != nil) != tt.wantErr { + t.Errorf("getPrefix() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotPath != tt.wantPath { + t.Errorf("getPrefix() gotPath = %v, wantPath %v", gotPath, tt.wantPath) + } + if gotSingle != tt.wantSingle { + t.Errorf("getPrefix() gotSingle = %v, wantSingle %v", gotSingle, tt.wantSingle) + } + }) + } +}