From cf2ce7436a588b71ee1ce9c60b1cdcc6dc0bac00 Mon Sep 17 00:00:00 2001 From: Shiming Zhang Date: Sat, 27 Jul 2024 16:15:34 +0800 Subject: [PATCH] Add augerctl get Signed-off-by: Shiming Zhang --- cmd/augerctl/README.md | 105 +++++++++++++ cmd/augerctl/command/ctl.go | 59 ++++++++ cmd/augerctl/command/get_command.go | 124 +++++++++++++++ cmd/augerctl/command/global.go | 85 +++++++++++ cmd/augerctl/command/printer.go | 64 ++++++++ cmd/augerctl/command/printer_yaml.go | 32 ++++ cmd/augerctl/main.go | 31 ++++ go.mod | 11 ++ go.sum | 29 ++++ pkg/client/client.go | 124 +++++++++++++++ pkg/client/client_get.go | 111 ++++++++++++++ pkg/client/util.go | 85 +++++++++++ pkg/client/util_test.go | 217 +++++++++++++++++++++++++++ 13 files changed, 1077 insertions(+) create mode 100644 cmd/augerctl/README.md create mode 100644 cmd/augerctl/command/ctl.go create mode 100644 cmd/augerctl/command/get_command.go create mode 100644 cmd/augerctl/command/global.go create mode 100644 cmd/augerctl/command/printer.go create mode 100644 cmd/augerctl/command/printer_yaml.go create mode 100644 cmd/augerctl/main.go create mode 100644 pkg/client/client.go create mode 100644 pkg/client/client_get.go create mode 100644 pkg/client/util.go create mode 100644 pkg/client/util_test.go diff --git a/cmd/augerctl/README.md b/cmd/augerctl/README.md new file mode 100644 index 0000000..8540090 --- /dev/null +++ b/cmd/augerctl/README.md @@ -0,0 +1,105 @@ +augerctl +======== + +`augerctl` is a command line client for [etcd][etcd]. +It can be used in scripts or for administrators to explore an etcd cluster. +Designed only for [Kubernetes][kubernetes] specific etcd, the commands are as close to [kubectl][kubectl] as possible. + +## Getting augerctl + +TODO: The latest release is available as a binary at [Github][github-release] along with etcd. + +augerctl can also be built from source using the build script found in the parent directory. + +## Configuration + +### --endpoint ++ a comma-delimited list of machine addresses in the cluster ++ default: `"http://127.0.0.1:2379"` + +### --cert-file ++ identify HTTPS client using this SSL certificate file ++ default: none + +### --key-file ++ identify HTTPS client using this SSL key file ++ default: none + +### --ca-file ++ verify certificates of HTTPS-enabled servers using this CA bundle ++ default: none + +### --user ++ provide username[:password] ++ default: none + +### --password ++ provide password ++ default: none + +### --output, -o ++ output response in the given format (`yaml`) ++ default: `"yaml"` + +## Usage + +### Setting Key Values + +TODO + +### Retrieving a key value + +List a single services with namespace "default" + +``` bash +augerctl get services -n default kubernetes +``` + +List a single resource without namespaced + +``` bash +augerctl get priorityclasses system-node-critical +``` + +List all leases with namespace "kube-system" + +``` bash +augerctl get leases -n kube-system +``` + +List all resources + +``` bash +augerctl get +``` + +### Deleting a key + +TODO + +### Watching for changes + +TODO + +## Endpoint + +If the etcd cluster isn't available on `http://127.0.0.1:2379`, specify a `--endpoint` 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/cmd/augerctl/command/ctl.go b/cmd/augerctl/command/ctl.go new file mode 100644 index 0000000..2177424 --- /dev/null +++ b/cmd/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 augerctl is A simple command line client for directly access 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 access data objects stored in etcd by Kubernetes.", + } + cmd.PersistentFlags().StringSliceVar(&flags.Endpoints, "endpoints", []string{"127.0.0.1:2379"}, "gRPC endpoints") + + 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", "", "identify secure client using this TLS certificate file") + cmd.PersistentFlags().StringVar(&flags.TLS.KeyFile, "key", "", "identify secure client using this TLS key file") + cmd.PersistentFlags().StringVar(&flags.TLS.TrustedCAFile, "cacert", "", "verify certificates of TLS-enabled secure servers using this CA bundle") + cmd.PersistentFlags().StringVar(&flags.User, "user", "", "username for authentication") + cmd.PersistentFlags().StringVar(&flags.Password, "password", "", "password for authentication") + + cmd.AddCommand( + newCtlGetCommand(flags), + ) + return cmd +} diff --git a/cmd/augerctl/command/get_command.go b/cmd/augerctl/command/get_command.go new file mode 100644 index 0000000..e6ed7b5 --- /dev/null +++ b/cmd/augerctl/command/get_command.go @@ -0,0 +1,124 @@ +/* +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 services with namespace "default" + augerctl get services -n default kubernetes + + # List a single resource without namespaced + augerctl get priorityclasses system-node-critical + + # List all leases with namespace "kube-system" + augerctl get leases -n kube-system + + # List all resources + augerctl get +` +) + +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.WithGR(targetGr), + client.WithPageLimit(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/cmd/augerctl/command/global.go b/cmd/augerctl/command/global.go new file mode 100644 index 0000000..fc17d9c --- /dev/null +++ b/cmd/augerctl/command/global.go @@ -0,0 +1,85 @@ +/* +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" + + "github.com/etcd-io/auger/pkg/client" + + clientv3 "go.etcd.io/etcd/client/v3" + "strings" +) + +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 { + cfg.Username = f.User + // TODO: + } else { + cfg.Username = splitted[0] + cfg.Password = splitted[1] + } + } else { + cfg.Username = f.User + cfg.Password = f.Password + } + } + + return cfg, nil +} diff --git a/cmd/augerctl/command/printer.go b/cmd/augerctl/command/printer.go new file mode 100644 index 0000000..bf21e6d --- /dev/null +++ b/cmd/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/cmd/augerctl/command/printer_yaml.go b/cmd/augerctl/command/printer_yaml.go new file mode 100644 index 0000000..c7abb98 --- /dev/null +++ b/cmd/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/cmd/augerctl/main.go b/cmd/augerctl/main.go new file mode 100644 index 0000000..49c0fea --- /dev/null +++ b/cmd/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/cmd/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 2a52a95..34e8b44 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,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 @@ -14,6 +16,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 @@ -24,9 +28,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.27.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.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 60ebd0a..702c3d8 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..cb56557 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,124 @@ +/* +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" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" + + 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 + response func(kv *KeyValue) error + pageLimit int64 + revision int64 +} + +func (o op) getPrefix(prefix string) (string, bool, error) { + var single bool + var arr [4]string + s := arr[:0] + s = append(s, prefix) + + if !o.gr.Empty() { + p, err := PrefixFromGR(o.gr) + if err != nil { + return "", false, err + } + s = append(s, p) + if o.namespace != "" { + s = append(s, o.namespace) + } + if o.name != "" { + s = append(s, o.name) + single = true + } + } + return strings.Join(s, "/"), single, nil +} + +// OpOption is the option for the operation. +type OpOption func(*op) + +// WithGR sets the gr for the target. +func WithGR(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 + } +} + +// WithPageLimit sets the page limit for the target. +func WithPageLimit(pageLimit int64) OpOption { + return func(o *op) { + o.pageLimit = pageLimit + } +} + +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 +} diff --git a/pkg/client/client_get.go b/pkg/client/client_get.go new file mode 100644 index 0000000..06a953c --- /dev/null +++ b/pkg/client/client_get.go @@ -0,0 +1,111 @@ +/* +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) { + opt := opOption(opOpts) + if opt.response == nil { + return 0, fmt.Errorf("response is required") + } + + prefix, single, err := opt.getPrefix(prefix) + if err != nil { + return 0, err + } + + opts := []clientv3.OpOption{} + + if single || opt.pageLimit == 0 { + if !single { + opts = append(opts, clientv3.WithPrefix()) + } + resp, err := c.client.Get(ctx, prefix, opts...) + if err != nil { + return 0, err + } + for _, kv := range resp.Kvs { + r := &KeyValue{ + Key: kv.Key, + Value: kv.Value, + } + err = opt.response(r) + if err != nil { + return 0, err + } + } + return resp.Header.Revision, nil + } + + opts = append(opts, clientv3.WithLimit(opt.pageLimit)) + if opt.revision != 0 { + rev = opt.revision + opts = append(opts, clientv3.WithRev(rev)) + } + + var key string + if len(prefix) == 0 { + // If len(s.prefix) == 0, we will sync the entire key-value space. + // We then range from the smallest key (0x00) to the end. + opts = append(opts, clientv3.WithFromKey()) + key = "\x00" + } else { + // If len(s.prefix) != 0, we will sync key-value space with given prefix. + // We then range from the prefix to the next prefix if exists. Or we will + // range from the prefix to the end if the next prefix does not exists. + opts = append(opts, clientv3.WithRange(clientv3.GetPrefixRangeEnd(prefix))) + key = prefix + } + + for { + resp, err := c.client.Get(ctx, key, opts...) + if err != nil { + return 0, err + } + + for _, kv := range resp.Kvs { + r := &KeyValue{ + Key: kv.Key, + Value: kv.Value, + } + err = opt.response(r) + if err != nil { + return 0, err + } + } + + 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..d4105de --- /dev/null +++ b/pkg/client/util.go @@ -0,0 +1,85 @@ +/* +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 ( + "strings" + + "github.com/etcd-io/auger/pkg/encoding" + "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 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 +} + +// MediaTypeFromGR returns the media type of the given GroupResource. +func MediaTypeFromGR(gr schema.GroupResource) (mediaType string, err error) { + if _, ok := specialDefaultMediaTypes[gr.Group]; ok { + return encoding.JsonMediaType, nil + } + + if !strings.Contains(gr.Group, ".") { + return encoding.StorageBinaryMediaType, nil + } + + // TODO: This can cause errors if custom resources use this group. + if strings.HasSuffix(gr.Group, ".k8s.io") { + return encoding.StorageBinaryMediaType, nil + } + + return encoding.JsonMediaType, nil +} diff --git a/pkg/client/util_test.go b/pkg/client/util_test.go new file mode 100644 index 0000000..7b5795d --- /dev/null +++ b/pkg/client/util_test.go @@ -0,0 +1,217 @@ +/* +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" + + "github.com/etcd-io/auger/pkg/encoding" + "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, want %v", gotPrefix, tt.wantPrefix) + } + }) + } +} + +func TestMediaTypeFromGR(t *testing.T) { + type args struct { + gr schema.GroupResource + } + tests := []struct { + name string + args args + wantMediaType string + wantErr bool + }{ + { + name: "pod", + args: args{ + gr: schema.GroupResource{ + Group: "", + Resource: "pods", + }, + }, + wantMediaType: encoding.StorageBinaryMediaType, + }, + { + name: "deployment", + args: args{ + gr: schema.GroupResource{ + Group: "apps", + Resource: "deployments", + }, + }, + wantMediaType: encoding.StorageBinaryMediaType, + }, + { + name: "service", + args: args{ + gr: schema.GroupResource{ + Group: "", + Resource: "services", + }, + }, + wantMediaType: encoding.StorageBinaryMediaType, + }, + { + name: "ingress", + args: args{ + gr: schema.GroupResource{ + Group: "networking.k8s.io", + Resource: "ingresses", + }, + }, + wantMediaType: encoding.StorageBinaryMediaType, + }, + { + name: "scheduling.k8s.io", + args: args{ + gr: schema.GroupResource{ + Group: "scheduling.k8s.io", + Resource: "priorityclasses", + }, + }, + wantMediaType: encoding.StorageBinaryMediaType, + }, + { + name: "apiextensions.k8s.io", + args: args{ + gr: schema.GroupResource{ + Group: "apiextensions.k8s.io", + Resource: "customresourcedefinitions", + }, + }, + wantMediaType: encoding.JsonMediaType, + }, + { + name: "x-k8s.io", + args: args{ + gr: schema.GroupResource{ + Group: "auger.x-k8s.io", + Resource: "foo", + }, + }, + wantMediaType: encoding.JsonMediaType, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMediaType, err := MediaTypeFromGR(tt.args.gr) + if (err != nil) != tt.wantErr { + t.Errorf("MediaTypeFromGR() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotMediaType != tt.wantMediaType { + t.Errorf("MediaTypeFromGR() gotMediaType = %v, want %v", gotMediaType, tt.wantMediaType) + } + }) + } +}