From afd13921739f5c3933d2998799daeaf54d7222c5 Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Sun, 14 Jan 2018 22:39:05 -0800 Subject: [PATCH] *: refactor generation and add type registration --- .gitignore | 1 + .travis.yml | 4 +- Makefile | 45 ++++ README.md | 171 +++++++++++---- client.go | 290 ++++++++------------------ client_test.go | 381 +++++++++------------------------ codec.go | 73 ++++--- discovery.go | 35 ++-- discovery_test.go | 6 +- examples/api-errors.go | 7 +- examples/create-resource.go | 5 +- examples/custom-resources.go | 57 +++++ examples/in-cluster-client.go | 5 +- gen.go | 382 ---------------------------------- gen.sh | 110 ---------- resource.go | 168 +++++++++++++++ resource_test.go | 119 +++++++++++ scripts/generate.sh | 54 +++++ scripts/get-protoc.sh | 9 + scripts/git-diff.sh | 7 + scripts/go-install.sh | 16 ++ scripts/register.go | 341 ++++++++++++++++++++++++++++++ scripts/time.go.partial | 31 +++ tprs.go | 163 --------------- watch.go | 199 ++++++++++++++++++ watch_test.go | 105 ++++++++++ 26 files changed, 1549 insertions(+), 1235 deletions(-) create mode 100644 examples/custom-resources.go delete mode 100644 gen.go delete mode 100755 gen.sh create mode 100644 resource.go create mode 100644 resource_test.go create mode 100755 scripts/generate.sh create mode 100755 scripts/get-protoc.sh create mode 100755 scripts/git-diff.sh create mode 100755 scripts/go-install.sh create mode 100644 scripts/register.go create mode 100644 scripts/time.go.partial delete mode 100644 tprs.go create mode 100644 watch.go create mode 100644 watch_test.go diff --git a/.gitignore b/.gitignore index 7e2f179..0a0b4f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ assets +_output/ diff --git a/.travis.yml b/.travis.yml index 847f2fd..f7b78fc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ language: go go: - - 1.7.5 - 1.8 + - 1.9 env: # Maybe run minikube later? @@ -13,8 +13,10 @@ install: - go get -v github.com/ghodss/yaml # Required for examples. script: + - make - make test - make test-examples + - make verify-generate notifications: diff --git a/Makefile b/Makefile index cdade05..e259784 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ +KUBE_VERSION=1.9.1 + +build: + go build -v ./... + test: go test -v ./... @@ -5,3 +10,43 @@ test-examples: @for example in $(shell find examples/ -name '*.go'); do \ go build -v $$example || exit 1; \ done + +.PHONY: generate +generate: _output/kubernetes _output/bin/protoc _output/bin/gomvpkg _output/bin/protoc-gen-gofast _output/src/github.com/golang/protobuf + ./scripts/generate.sh + go run scripts/register.go + cp scripts/time.go.partial apis/meta/v1/time.go + +.PHONY: verify-generate +verify-generate: generate + ./scripts/git-diff.sh + +_output/bin/protoc-gen-gofast: + ./scripts/go-install.sh \ + https://github.com/gogo/protobuf \ + github.com/gogo/protobuf \ + github.com/gogo/protobuf/protoc-gen-gofast \ + tags/v0.5 + +_output/bin/gomvpkg: + ./scripts/go-install.sh \ + https://github.com/golang/tools \ + golang.org/x/tools \ + golang.org/x/tools/cmd/gomvpkg \ + fbec762f837dc349b73d1eaa820552e2ad177942 + +_output/src/github.com/golang/protobuf: + git clone https://github.com/golang/protobuf _output/src/github.com/golang/protobuf + +_output/bin/protoc: + ./scripts/get-protoc.sh + +_output/kubernetes: + mkdir -p _output + curl -o _output/kubernetes.zip -L https://github.com/kubernetes/kubernetes/archive/v$(KUBE_VERSION).zip + unzip _output/kubernetes.zip -d _output > /dev/null + mv _output/kubernetes-$(KUBE_VERSION) _output/kubernetes + +.PHONY: clean +clean: + rm -rf _output diff --git a/README.md b/README.md index 8cbdaa3..c833f86 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ import ( "log" "github.com/ericchiang/k8s" + corev1 "github.com/ericchiang/k8s/apis/core/v1" ) func main() { @@ -21,8 +22,8 @@ func main() { log.Fatal(err) } - nodes, err := client.CoreV1().ListNodes(context.Background()) - if err != nil { + var nodes corev1.NodeList + if err := client.List(context.Background(), "", &nodes); err != nil { log.Fatal(err) } for _, node := range nodes.Items { @@ -33,9 +34,9 @@ func main() { ## Should I use this or client-go? -client-go is a framework for building production ready controllers, components that regularly watch API resources and push the system towards a desired state. If you're writing a program that watches several resources in a loop for long durations, client-go's informers framework is a battle tested solution which will scale with the size of the cluster. +client-go is a framework for building production ready controllers, components that regularly watch API resources and push the system towards a desired state. If you're writing a program that watches several resources in a loop for long durations, client-go's informers framework is a battle tested solution which will scale with the size of the cluster. -This client should be used by programs that just need to talk to the Kubernetes API without prescriptive solutions for caching, reconciliation on failures, or work queues. This often includes components are relatively Kubernetes agnostic, but use the Kubernetes API for small tasks when running in Kubernetes. For example, performing leader election or persisting small amounts of state in annotations or configmaps. +This client should be used by programs that just need to talk to the Kubernetes API without prescriptive solutions for caching, reconciliation on failures, or work queues. This often includes components are relatively Kubernetes agnostic, but use the Kubernetes API for small tasks when running in Kubernetes. For example, performing leader election or persisting small amounts of state in annotations or configmaps. TL;DR - Use client-go if you're writing a controller. @@ -46,78 +47,173 @@ TL;DR - Use client-go if you're writing a controller. * [github.com/golang/protobuf/proto][go-proto] (protobuf serialization) * [golang.org/x/net/http2][go-http2] (HTTP/2 support) -## Versioned supported +## Usage -This client supports every API group version present since 1.3. +### Create, update, delete -## Usage +The type of the object passed to `Create`, `Update`, and `Delete` determine the resource being acted on. + +```go +configMap := &corev1.ConfigMap{ + Metadata: &metav1.ObjectMeta{ + Name: k8s.String("my-configmap"), + Namespace: k8s.String("my-namespace"), + }, + Data: map[string]string{"hello": "world"}, +} + +if err := client.Create(ctx, configMap); err != nil { + // handle error +} + +configMap.Data["hello"] = "kubernetes" + +if err := client.Update(ctx, configMap); err != nil { + // handle error +} + +if err := client.Delete(ctx, configMap); err != nil { + // handle error +} +``` + +### Get, list, watch + +Getting a resource requires providing a namespace (for namespaced objects) and a name. -### Namespace +```go +// Get the "cluster-info" configmap from the "kube-public" namespace +var configMap corev1.ConfigMap +err := client.Get(ctx, "kube-public", "cluster-info", &configMap) +``` -When performing a list or watch operation, the namespace to list or watch in is provided as an argument. +When performing a list operation, the namespace to list or watch is also required. ```go -pods, err := core.ListPods(ctx, "custom-namespace") // Pods from the "custom-namespace" +// Pods from the "custom-namespace" +var pods corev1.PodList +err := client.List(ctx, "custom-namespace", &pods) ``` A special value `AllNamespaces` indicates that the list or watch should be performed on all cluster resources. ```go -pods, err := core.ListPods(ctx, k8s.AllNamespaces) // Pods in all namespaces. +// Pods in all namespaces +var pods corev1.PodList +err := client.List(ctx, k8s.AllNamespaces, &pods) ``` -Both in-cluster and out-of-cluster clients are initialized with a primary namespace. This is the recommended value to use when listing or watching. +Watches require a example type to determine what resource they're watching. `Watch` returns an type which can be used to receive a stream of events. These events include resources of the same kind and the kind of the event (added, modified, deleted). ```go -client, err := k8s.NewInClusterClient() +// Watch configmaps in the "kube-system" namespace +var configMap corev1.ConfigMap +watcher, err := client.Watch(ctx, "kube-system", &configMap) if err != nil { // handle error } +defer watcher.Close() -// List pods in the namespace the client is running in. -pods, err := client.CoreV1().ListPods(ctx, client.Namespace) +for { + cm := new(corev1.ConfigMap) + eventType, err := watcher.Next(cm) + if err != nil { + // watcher encountered and error, exit or create a new watcher + } + fmt.Println(eventType, *cm.Metadata.Name) +} ``` -### Label selectors - -Label selectors can be provided to any list operation. +Both in-cluster and out-of-cluster clients are initialized with a primary namespace. This is the recommended value to use when listing or watching. ```go -l := new(k8s.LabelSelector) -l.Eq("tier", "production") -l.In("app", "database", "frontend") +client, err := k8s.NewInClusterClient() +if err != nil { + // handle error +} -pods, err := client.CoreV1().ListPods(ctx, client.Namespace, l.Selector()) +// List pods in the namespace the client is running in. +var pods corev1.PodList +err := client.List(ctx, client.Namespace, &pods) ``` -### Working with resources +### Custom resources -Use the generated API types directly to create and modify resources. +Client operations support user defined resources, such as resources provided by [CustomResourceDefinitions][crds] and [aggregated API servers][custom-api-servers]. To use a custom resource, define an equivalent Go struct then register it with the `k8s` package. By default the client will use JSON serialization when encoding and decoding custom resources. ```go import ( - "context" - "github.com/ericchiang/k8s" - "github.com/ericchiang/k8s/api/v1" metav1 "github.com/ericchiang/k8s/apis/meta/v1" ) -func createConfigMap(client *k8s.Client, name string, values map[string]string) error { - cm := &v1.ConfigMap{ +type MyResource struct { + Metadata *metav1.ObjectMeta `json:"metadata"` + Foo string `json:"foo"` + Bar int `json:"bar"` +} + +// Required for MyResource to implement k8s.Resource +func (m *MyResource) GetMetadata() *metav1.ObjectMeta { + return m.Metadata +} + +type MyResourceList struct { + Metadata *metav1.ListMeta `json:"metadata"` + Items []MyResource `json:"items"` +} + +// Require for MyResourceList to implement k8s.ResourceList +func (m *MyResourceList) GetMetadata() *metav1.ListMeta { + return m.Metadata +} + +func init() { + // Register resources with the k8s package. + k8s.Register("resource.example.com", "v1", "myresources", true, &MyResource{}) + k8s.RegisterList("resource.example.com", "v1", "myresources", true, &MyResourceList{}) +} +``` + +Once registered, the library can use the custom resources like any other. + +``` +func do(ctx context.Context, client *k8s.Client, namespace string) error { + r := &MyResource{ Metadata: &metav1.ObjectMeta{ - Name: &name, - Namespace: &client.Namespace, + Name: k8s.String("my-custom-resource"), + Namespace: &namespace, }, - Data: values, + Foo: "hello, world!", + Bar: 42, } - // Will return the created configmap as well. - _, err := client.CoreV1().CreateConfigMap(context.TODO(), cm) - return err + if err := client.Create(ctx, r); err != nil { + return fmt.Errorf("create: %v", err) + } + r.Bar = -8 + if err := client.Update(ctx, r); err != nil { + return fmt.Errorf("update: %v", err) + } + if err := client.Delete(ctx, r); err != nil { + return fmt.Errorf("delete: %v", err) + } + return nil } ``` -API structs use pointers to `int`, `bool`, and `string` types to differentiate between the zero value and an unsupplied one. This package provides [convenience methods][string] for creating pointers to literals of basic types. +If the custom type implements [`proto.Message`][proto-msg], the client will prefer protobuf when encoding and decoding the type. + +### Label selectors + +Label selectors can be provided to any list operation. + +```go +l := new(k8s.LabelSelector) +l.Eq("tier", "production") +l.In("app", "database", "frontend") + +pods, err := client.CoreV1().ListPods(ctx, client.Namespace, l.Selector()) +``` ### Creating out-of-cluster clients @@ -188,3 +284,6 @@ func createConfigMap(client *k8s.Client, name string, values map[string]string) [k8s-error]: https://godoc.org/github.com/ericchiang/k8s#APIError [config]: https://godoc.org/github.com/ericchiang/k8s#Config [string]: https://godoc.org/github.com/ericchiang/k8s#String +[crds]: https://kubernetes.io/docs/tasks/access-kubernetes-api/extend-api-custom-resource-definitions/ +[custom-api-servers]: https://kubernetes.io/docs/concepts/api-extension/apiserver-aggregation/ +[proto-msg]: https://godoc.org/github.com/golang/protobuf/proto#Message diff --git a/client.go b/client.go index 1f01f5d..fb8254a 100644 --- a/client.go +++ b/client.go @@ -1,15 +1,23 @@ /* Package k8s implements a Kubernetes client. - c, err := k8s.NewInClusterClient() - if err != nil { - // handle error - } - extensions := c.ExtensionsV1Beta1() + import ( + "github.com/ericchiang/k8s" + appsv1 "github.com/ericchiang/k8s/apis/apps/v1" + metav1 "github.com/ericchiang/k8s/apis/meta/v1" + ) - ingresses, err := extensions.ListIngresses(ctx, c.Namespace) - if err != nil { - // handle error + func listDeployments() (*apssv1.DeploymentList, error) { + c, err := k8s.NewInClusterClient() + if err != nil { + return nil, err + } + + var deployments appsv1.DeploymentList + if err := c.List(ctx, "my-namespace", &deployments); err != nil { + return nil, err + } + return deployments, nil } */ @@ -21,26 +29,19 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" - "encoding/binary" "errors" "fmt" "io" "io/ioutil" "net" "net/http" - "net/url" "os" - "path" "strconv" - "strings" "time" "golang.org/x/net/http2" - "github.com/ericchiang/k8s/api/unversioned" - "github.com/ericchiang/k8s/runtime" - "github.com/ericchiang/k8s/watch/versioned" - "github.com/golang/protobuf/proto" + metav1 "github.com/ericchiang/k8s/apis/meta/v1" ) const ( @@ -55,17 +56,6 @@ const ( // String returns a pointer to a string. Useful for creating API objects // that take pointers instead of literals. -// -// cm := &v1.ConfigMap{ -// Metadata: &v1.ObjectMeta{ -// Name: k8s.String("myconfigmap"), -// Namespace: k8s.String("default"), -// }, -// Data: map[string]string{ -// "foo": "bar", -// }, -// } -// func String(s string) *string { return &s } // Int is a convenience for converting an int literal to a pointer to an int. @@ -367,7 +357,7 @@ func newClient(cluster Cluster, user AuthInfo, namespace string) (*Client, error // APIError is an error from a unexpected status code. type APIError struct { // The status object returned by the Kubernetes API, - Status *unversioned.Status + Status *metav1.Status // Status code returned by the HTTP request. // @@ -384,17 +374,17 @@ func (e *APIError) Error() string { return fmt.Sprintf("%#v", e) } -func checkStatusCode(c *codec, statusCode int, body []byte) error { +func checkStatusCode(contentType string, statusCode int, body []byte) error { if statusCode/100 == 2 { return nil } - return newAPIError(c, statusCode, body) + return newAPIError(contentType, statusCode, body) } -func newAPIError(c *codec, statusCode int, body []byte) error { - status := new(unversioned.Status) - if err := c.unmarshal(body, status); err != nil { +func newAPIError(contentType string, statusCode int, body []byte) error { + status := new(metav1.Status) + if err := unmarshal(body, contentType, status); err != nil { return fmt.Errorf("decode error status %d: %v", statusCode, err) } return &APIError{status, statusCode} @@ -407,118 +397,95 @@ func (c *Client) client() *http.Client { return c.Client } -// The following methods hold the logic for interacting with the Kubernetes API. Generated -// clients are thin wrappers on top of these methods. +// Create creates a resource of a registered type. The API version and resource +// type is determined by the type of the req argument. The result is unmarshaled +// into req. // -// This client implements specs in the "API Conventions" developer document, which can be -// found here: +// configMap := corev1.ConfigMap{ +// Metadata: &metav1.ObjectMeta{ +// Name: k8s.String("my-configmap"), +// Namespace: k8s.String("my-namespace"), +// }, +// Data: map[string]string{ +// "my-key": "my-val", +// }, +// } +// if err := client.Create(ctx, &configMap); err != nil { +// // handle error +// } +// // resource is updated with response of create request +// fmt.Println(conifgMap.Metaata.GetCreationTimestamp()) // -// https://github.com/kubernetes/kubernetes/blob/master/docs/devel/api-conventions.md - -func (c *Client) urlFor(apiGroup, apiVersion, namespace, resource, name string, options ...Option) string { - basePath := "apis/" - if apiGroup == "" { - basePath = "api/" - } - - var p string - if namespace != "" { - p = path.Join(basePath, apiGroup, apiVersion, "namespaces", namespace, resource, name) - } else { - p = path.Join(basePath, apiGroup, apiVersion, resource, name) - } - endpoint := "" - if strings.HasSuffix(c.Endpoint, "/") { - endpoint = c.Endpoint + p - } else { - endpoint = c.Endpoint + "/" + p - } - if len(options) == 0 { - return endpoint - } - - v := url.Values{} - for _, option := range options { - key, val := option.queryParam() - v.Set(key, val) - } - return endpoint + "?" + v.Encode() -} - -func (c *Client) urlForPath(path string) string { - if strings.HasPrefix(path, "/") { - path = path[1:] - } - if strings.HasSuffix(c.Endpoint, "/") { - return c.Endpoint + path - } - return c.Endpoint + "/" + path -} - -func (c *Client) create(ctx context.Context, codec *codec, verb, url string, req, resp interface{}) error { - body, err := codec.marshal(req) +func (c *Client) Create(ctx context.Context, req Resource, options ...Option) error { + url, err := resourceURL(c.Endpoint, req, false, options...) if err != nil { return err } + return c.do(ctx, "POST", url, req, req) +} - r, err := c.newRequest(ctx, verb, url, bytes.NewReader(body)) +func (c *Client) Delete(ctx context.Context, req Resource, options ...Option) error { + url, err := resourceURL(c.Endpoint, req, true, options...) if err != nil { return err } - r.Header.Set("Content-Type", codec.contentType) - r.Header.Set("Accept", codec.contentType) + return c.do(ctx, "DELETE", url, nil, nil) +} - re, err := c.client().Do(r) +func (c *Client) Update(ctx context.Context, req Resource, options ...Option) error { + url, err := resourceURL(c.Endpoint, req, true, options...) if err != nil { return err } - defer re.Body.Close() + return c.do(ctx, "PUT", url, req, req) +} - respBody, err := ioutil.ReadAll(re.Body) +func (c *Client) Get(ctx context.Context, namespace, name string, resp Resource, options ...Option) error { + url, err := resourceGetURL(c.Endpoint, namespace, name, resp, options...) if err != nil { - return fmt.Errorf("read body: %v", err) - } - - if err := checkStatusCode(codec, re.StatusCode, respBody); err != nil { return err } - return codec.unmarshal(respBody, resp) + return c.do(ctx, "GET", url, nil, resp) } -func (c *Client) delete(ctx context.Context, codec *codec, url string) error { - r, err := c.newRequest(ctx, "DELETE", url, nil) +func (c *Client) List(ctx context.Context, namespace string, resp ResourceList, options ...Option) error { + url, err := resourceListURL(c.Endpoint, namespace, resp, options...) if err != nil { return err } - r.Header.Set("Accept", codec.contentType) + return c.do(ctx, "GET", url, nil, resp) +} - re, err := c.client().Do(r) - if err != nil { - return err +func (c *Client) do(ctx context.Context, verb, url string, req, resp interface{}) error { + var ( + contentType string + body io.Reader + ) + if req != nil { + ct, data, err := marshal(req) + if err != nil { + return fmt.Errorf("encoding object: %v", err) + } + contentType = ct + body = bytes.NewReader(data) } - defer re.Body.Close() - - respBody, err := ioutil.ReadAll(re.Body) + r, err := http.NewRequest(verb, url, body) if err != nil { - return fmt.Errorf("read body: %v", err) + return fmt.Errorf("new request: %v", err) } - - if err := checkStatusCode(codec, re.StatusCode, respBody); err != nil { - return err + if contentType != "" { + r.Header.Set("Content-Type", contentType) + r.Header.Set("Accept", contentType) + } else if resp != nil { + r.Header.Set("Accept", contentTypeFor(resp)) } - return nil -} - -// get can be used to either get or list a given resource. -func (c *Client) get(ctx context.Context, codec *codec, url string, resp interface{}) error { - r, err := c.newRequest(ctx, "GET", url, nil) - if err != nil { - return err + if c.SetHeaders != nil { + c.SetHeaders(r.Header) } - r.Header.Set("Accept", codec.contentType) + re, err := c.client().Do(r) if err != nil { - return err + return fmt.Errorf("performing request: %v", err) } defer re.Body.Close() @@ -527,95 +494,14 @@ func (c *Client) get(ctx context.Context, codec *codec, url string, resp interfa return fmt.Errorf("read body: %v", err) } - if err := checkStatusCode(codec, re.StatusCode, respBody); err != nil { + respCT := re.Header.Get("Content-Type") + if err := checkStatusCode(respCT, re.StatusCode, respBody); err != nil { return err } - return codec.unmarshal(respBody, resp) -} - -var unknownPrefix = []byte{0x6b, 0x38, 0x73, 0x00} - -func parseUnknown(b []byte) (*runtime.Unknown, error) { - if !bytes.HasPrefix(b, unknownPrefix) { - return nil, errors.New("bytes did not start with expected prefix") - } - - var u runtime.Unknown - if err := proto.Unmarshal(b[len(unknownPrefix):], &u); err != nil { - return nil, err - } - return &u, nil -} - -type event struct { - event *versioned.Event - unknown *runtime.Unknown -} - -type watcher struct { - r io.ReadCloser -} - -func (w *watcher) Close() error { - return w.r.Close() -} - -// Decode the next event from a watch stream. -// -// See: https://github.com/kubernetes/community/blob/master/contributors/design-proposals/protobuf.md#streaming-wire-format -func (w *watcher) next() (*versioned.Event, *runtime.Unknown, error) { - length := make([]byte, 4) - if _, err := io.ReadFull(w.r, length); err != nil { - return nil, nil, err - } - - body := make([]byte, int(binary.BigEndian.Uint32(length))) - if _, err := io.ReadFull(w.r, body); err != nil { - return nil, nil, fmt.Errorf("read frame body: %v", err) - } - - var event versioned.Event - if err := proto.Unmarshal(body, &event); err != nil { - return nil, nil, err - } - - if event.Object == nil { - return nil, nil, fmt.Errorf("event had no underlying object") - } - - unknown, err := parseUnknown(event.Object.Raw) - if err != nil { - return nil, nil, err - } - - return &event, unknown, nil -} - -func (c *Client) watch(ctx context.Context, url string) (*watcher, error) { - if strings.Contains(url, "?") { - url = url + "&watch=true" - } else { - url = url + "?watch=true" - } - r, err := c.newRequest(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - r.Header.Set("Accept", "application/vnd.kubernetes.protobuf;type=watch") - resp, err := c.client().Do(r) - if err != nil { - return nil, err - } - - if resp.StatusCode/100 != 2 { - body, err := ioutil.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, err + if resp != nil { + if err := unmarshal(respBody, respCT, resp); err != nil { + return fmt.Errorf("decode response: %v", err) } - return nil, newAPIError(pbCodec, resp.StatusCode, body) } - - w := &watcher{resp.Body} - return w, nil + return nil } diff --git a/client_test.go b/client_test.go index 4782e3d..d2656b4 100644 --- a/client_test.go +++ b/client_test.go @@ -1,19 +1,18 @@ -package k8s +package k8s_test import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" - "io" - "net/http" + "fmt" + "math/rand" "os" "os/exec" "reflect" - "strings" "testing" + "time" - "github.com/ericchiang/k8s/api/v1" + "github.com/ericchiang/k8s" + corev1 "github.com/ericchiang/k8s/apis/core/v1" metav1 "github.com/ericchiang/k8s/apis/meta/v1" ) @@ -30,7 +29,7 @@ To suppress this message, set: export K8S_CLIENT_TEST=0 ` -func newTestClient(t *testing.T) *Client { +func newTestClient(t *testing.T) *k8s.Client { if os.Getenv("K8S_CLIENT_TEST") == "0" { t.Skip("") } @@ -44,335 +43,124 @@ func newTestClient(t *testing.T) *Client { t.Fatalf("'kubectl config view -o json': %v %s", err, out) } - config := new(Config) + config := new(k8s.Config) if err := json.Unmarshal(out, config); err != nil { t.Fatalf("parse kubeconfig: %v '%s'", err, out) } - client, err := NewClient(config) + client, err := k8s.NewClient(config) if err != nil { t.Fatalf("new client: %v", err) } return client } -func newName() string { - b := make([]byte, 12) - if _, err := io.ReadFull(rand.Reader, b); err != nil { - panic(err) - } - return hex.EncodeToString(b) -} +var letters = []rune("abcdefghijklmnopqrstuvwxyz") -func TestNewTestClient(t *testing.T) { - newTestClient(t) -} - -func TestHTTP2(t *testing.T) { +func withNamespace(t *testing.T, test func(client *k8s.Client, namespace string)) { client := newTestClient(t) - req, err := http.NewRequest("GET", client.urlForPath("/api"), nil) - if err != nil { - t.Fatal(err) - } - resp, err := client.Client.Do(req) - if err != nil { - t.Fatal(err) + r := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]rune, 8) + for i := range b { + b[i] = letters[r.Intn(len(letters))] } - defer resp.Body.Close() - if !strings.HasPrefix(resp.Proto, "HTTP/2") { - t.Errorf("expected proto=HTTP/2.X, got=%s", resp.Proto) - } -} - -func TestListNodes(t *testing.T) { - client := newTestClient(t) - if _, err := client.CoreV1().ListNodes(context.Background()); err != nil { - t.Fatalf("failed to list nodes: %v", err) - } -} - -func TestConfigMaps(t *testing.T) { - client := newTestClient(t).CoreV1() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - name := newName() - labelVal := newName() - - cm := &v1.ConfigMap{ + name := "k8s-test-" + string(b) + namespace := corev1.Namespace{ Metadata: &metav1.ObjectMeta{ - Name: String(name), - Namespace: String("default"), - Labels: map[string]string{ - "testLabel": labelVal, - }, + Name: &name, }, - Data: map[string]string{ - "foo": "bar", - }, - } - got, err := client.CreateConfigMap(ctx, cm) - if err != nil { - t.Fatalf("create config map: %v", err) } - got.Data["zam"] = "spam" - _, err = client.UpdateConfigMap(ctx, got) - if err != nil { - t.Fatalf("update config map: %v", err) - } - - tests := []struct { - labelVal string - expNum int - }{ - {labelVal, 1}, - {newName(), 0}, + if err := client.Create(context.TODO(), &namespace); err != nil { + t.Fatalf("create namespace: %v", err) } - for _, test := range tests { - l := new(LabelSelector) - l.Eq("testLabel", test.labelVal) - - configMaps, err := client.ListConfigMaps(ctx, "default", l.Selector()) - if err != nil { - t.Errorf("failed to list configmaps: %v", err) - continue - } - got := len(configMaps.Items) - if got != test.expNum { - t.Errorf("expected selector to return %d items got %d", test.expNum, got) + defer func() { + if err := client.Delete(context.TODO(), &namespace); err != nil { + t.Fatalf("delete namespace: %v", err) } - } - - if err := client.DeleteConfigMap(ctx, *cm.Metadata.Name, *cm.Metadata.Namespace); err != nil { - t.Fatalf("delete config map: %v", err) - } + }() + test(client, name) } -func TestWatch(t *testing.T) { - client := newTestClient(t).CoreV1() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - w, err := client.WatchConfigMaps(ctx, "default") - if err != nil { - t.Fatal(err) - } - defer w.Close() +func TestNewTestClient(t *testing.T) { + newTestClient(t) +} - name := newName() - labelVal := newName() +func TestWithNamespace(t *testing.T) { + withNamespace(t, func(client *k8s.Client, namespace string) {}) +} - cm := &v1.ConfigMap{ - Metadata: &metav1.ObjectMeta{ - Name: String(name), - Namespace: String("default"), - Labels: map[string]string{ - "testLabel": labelVal, +func TestCreateConfigMap(t *testing.T) { + withNamespace(t, func(client *k8s.Client, namespace string) { + cm := &corev1.ConfigMap{ + Metadata: &metav1.ObjectMeta{ + Name: k8s.String("my-configmap"), + Namespace: &namespace, }, - }, - Data: map[string]string{ - "foo": "bar", - }, - } - got, err := client.CreateConfigMap(ctx, cm) - if err != nil { - t.Fatalf("create config map: %v", err) - } - - if event, gotFromWatch, err := w.Next(); err != nil { - t.Errorf("failed to get next watch: %v", err) - } else { - if *event.Type != EventAdded { - t.Errorf("expected event type %q got %q", EventAdded, *event.Type) } - if !reflect.DeepEqual(got, gotFromWatch) { - t.Errorf("object from add event did not match expected value") + if err := client.Create(context.TODO(), cm); err != nil { + t.Errorf("create configmap: %v", err) + return } - } - - got.Data["zam"] = "spam" - got, err = client.UpdateConfigMap(ctx, got) - if err != nil { - t.Fatalf("update config map: %v", err) - } - - if event, gotFromWatch, err := w.Next(); err != nil { - t.Errorf("failed to get next watch: %v", err) - } else { - if *event.Type != EventModified { - t.Errorf("expected event type %q got %q", EventModified, *event.Type) + got := new(corev1.ConfigMap) + if err := client.Get(context.TODO(), namespace, *cm.Metadata.Name, got); err != nil { + t.Errorf("get configmap: %v", err) + return } - if !reflect.DeepEqual(got, gotFromWatch) { - t.Errorf("object from modified event did not match expected value") + if !reflect.DeepEqual(cm, got) { + t.Errorf("expected configmap %#v, got=%#v", cm, got) } - } - tests := []struct { - labelVal string - expNum int - }{ - {labelVal, 1}, - {newName(), 0}, - } - for _, test := range tests { - l := new(LabelSelector) - l.Eq("testLabel", test.labelVal) - - configMaps, err := client.ListConfigMaps(ctx, "default", l.Selector()) - if err != nil { - t.Errorf("failed to list configmaps: %v", err) - continue - } - got := len(configMaps.Items) - if got != test.expNum { - t.Errorf("expected selector to return %d items got %d", test.expNum, got) - } - } - - if err := client.DeleteConfigMap(ctx, *cm.Metadata.Name, *cm.Metadata.Namespace); err != nil { - t.Fatalf("delete config map: %v", err) - } - if event, gotFromWatch, err := w.Next(); err != nil { - t.Errorf("failed to get next watch: %v", err) - } else { - if *event.Type != EventDeleted { - t.Errorf("expected event type %q got %q", EventDeleted, *event.Type) - } - - // Resource version will be different after a delete - got.Metadata.ResourceVersion = String("") - gotFromWatch.Metadata.ResourceVersion = String("") - - if !reflect.DeepEqual(got, gotFromWatch) { - t.Errorf("object from deleted event did not match expected value") + if err := client.Delete(context.TODO(), cm); err != nil { + t.Errorf("delete configmap: %v", err) + return } - } + }) } -// TestWatchNamespace ensures that creating a configmap in a non-default namespace is not returned while watching the default namespace -func TestWatchNamespace(t *testing.T) { - client := newTestClient(t).CoreV1() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - defaultWatch, err := client.WatchConfigMaps(ctx, "default") - if err != nil { - t.Fatal(err) - } - defer defaultWatch.Close() - - nonDefaultNamespaceName := newName() - defaultName := newName() - name := newName() - labelVal := newName() - - // Create a configmap in the default namespace so the "default" watch has something to return - defaultCM := &v1.ConfigMap{ - Metadata: &metav1.ObjectMeta{ - Name: String(defaultName), - Namespace: String("default"), - Labels: map[string]string{ - "testLabel": labelVal, - }, - }, - Data: map[string]string{ - "foo": "bar", - }, - } - defaultGot, err := client.CreateConfigMap(ctx, defaultCM) - if err != nil { - t.Fatalf("create config map: %v", err) - } - - // Create a non-default Namespace - ns := &v1.Namespace{ - Metadata: &metav1.ObjectMeta{ - Name: String(nonDefaultNamespaceName), - }, - } - if _, err := client.CreateNamespace(ctx, ns); err != nil { - t.Fatalf("create non-default-namespace: %v", err) - } - - // Create a configmap in the non-default namespace - nonDefaultCM := &v1.ConfigMap{ - Metadata: &metav1.ObjectMeta{ - Name: String(name), - Namespace: String(nonDefaultNamespaceName), - Labels: map[string]string{ - "testLabel": labelVal, - }, - }, - Data: map[string]string{ - "foo": "bar", - }, - } - nonDefaultGot, err := client.CreateConfigMap(ctx, nonDefaultCM) - if err != nil { - t.Fatalf("create config map: %v", err) - } - - // Watching the default namespace should not return the non-default namespace configmap, - // and instead return the previously created configmap in the default namespace - if _, gotFromWatch, err := defaultWatch.Next(); err != nil { - t.Errorf("failed to get next watch: %v", err) - } else { - if reflect.DeepEqual(nonDefaultGot, gotFromWatch) { - t.Errorf("config map in non-default namespace returned while watching default namespace") - } - if !reflect.DeepEqual(defaultGot, gotFromWatch) { - t.Errorf("object from add event did not match expected value") +func TestListConfigMap(t *testing.T) { + withNamespace(t, func(client *k8s.Client, namespace string) { + for i := 0; i < 5; i++ { + cm := &corev1.ConfigMap{ + Metadata: &metav1.ObjectMeta{ + Name: k8s.String(fmt.Sprintf("my-configmap-%d", i)), + Namespace: &namespace, + }, + } + if err := client.Create(context.TODO(), cm); err != nil { + t.Errorf("create configmap: %v", err) + return + } } - } - // Delete the config map in the default namespace first, then delete the non-default namespace config map. - // Only the former should be noticed by the default-watch. - - if err := client.DeleteConfigMap(ctx, *defaultCM.Metadata.Name, *defaultCM.Metadata.Namespace); err != nil { - t.Fatalf("delete config map: %v", err) - } - if err := client.DeleteConfigMap(ctx, *nonDefaultCM.Metadata.Name, *nonDefaultCM.Metadata.Namespace); err != nil { - t.Fatalf("delete config map: %v", err) - } - - if event, gotFromWatch, err := defaultWatch.Next(); err != nil { - t.Errorf("failed to get next watch: %v", err) - } else { - if *event.Type != EventDeleted { - t.Errorf("expected event type %q got %q", EventDeleted, *event.Type) + var configMapList corev1.ConfigMapList + if err := client.List(context.TODO(), namespace, &configMapList); err != nil { + t.Errorf("list configmaps: %v", err) + return } - // Resource version will be different after a delete - nonDefaultGot.Metadata.ResourceVersion = String("") - gotFromWatch.Metadata.ResourceVersion = String("") - - if reflect.DeepEqual(nonDefaultGot, gotFromWatch) { - t.Errorf("should not have received event from non-default namespace while watching default namespace") + if n := len(configMapList.Items); n != 5 { + t.Errorf("expected 5 configmaps, got %d", n) } - } - - if err := client.DeleteNamespace(ctx, nonDefaultNamespaceName); err != nil { - t.Fatalf("delete namespace: %v", err) - } + }) } func TestDefaultNamespace(t *testing.T) { - c := &Config{ - Clusters: []NamedCluster{ + c := &k8s.Config{ + Clusters: []k8s.NamedCluster{ { Name: "local", - Cluster: Cluster{ + Cluster: k8s.Cluster{ Server: "http://localhost:8080", }, }, }, - AuthInfos: []NamedAuthInfo{ + AuthInfos: []k8s.NamedAuthInfo{ { Name: "local", }, }, } - cli, err := NewClient(c) + cli, err := k8s.NewClient(c) if err != nil { t.Fatal(err) } @@ -380,3 +168,22 @@ func TestDefaultNamespace(t *testing.T) { t.Errorf("expected namespace=%q got=%q", "default", cli.Namespace) } } + +func Test404(t *testing.T) { + withNamespace(t, func(client *k8s.Client, namespace string) { + var configMap corev1.ConfigMap + err := client.Get(context.TODO(), namespace, "i-dont-exist", &configMap) + if err == nil { + t.Errorf("expected 404 error") + return + } + apiErr, ok := err.(*k8s.APIError) + if !ok { + t.Errorf("error was not of type APIError: %T %v", err, err) + return + } + if apiErr.Code != 404 { + t.Errorf("expected 404 error code, got %d", apiErr.Code) + } + }) +} diff --git a/codec.go b/codec.go index d209671..e4fdc0a 100644 --- a/codec.go +++ b/codec.go @@ -10,41 +10,56 @@ import ( "github.com/golang/protobuf/proto" ) -type codec struct { - contentType string - marshal func(interface{}) ([]byte, error) - unmarshal func([]byte, interface{}) error +const ( + contentTypePB = "application/vnd.kubernetes.protobuf" + contentTypeJSON = "application/json" +) + +func contentTypeFor(i interface{}) string { + if _, ok := i.(proto.Message); ok { + return contentTypePB + } + return contentTypeJSON } -var ( - // Kubernetes implements its own custom protobuf format to allow clients (and possibly - // servers) to use either JSON or protocol buffers. The protocol introduces a custom content - // type and magic bytes to signal the use of protobufs, and wraps each object with API group, - // version and resource data. - // - // The protocol spec which this client implements can be found here: - // - // https://github.com/kubernetes/kubernetes/blob/master/docs/proposals/protobuf.md - // - pbCodec = &codec{ - contentType: "application/vnd.kubernetes.protobuf", - marshal: marshalPB, - unmarshal: unmarshalPB, +// marshal encodes an object and returns the content type of that resource +// and the marshaled representation. +// +// marshal prefers protobuf encoding, but falls back to JSON. +func marshal(i interface{}) (string, []byte, error) { + if _, ok := i.(proto.Message); ok { + data, err := marshalPB(i) + return contentTypePB, data, err } - jsonCodec = &codec{ - contentType: "application/json", - marshal: json.Marshal, - unmarshal: json.Unmarshal, + data, err := json.Marshal(i) + return contentTypeJSON, data, err +} + +// unmarshal decoded an object given the content type of the encoded form. +func unmarshal(data []byte, contentType string, i interface{}) error { + msg, isPBMsg := i.(proto.Message) + if contentType == contentTypePB && isPBMsg { + if err := unmarshalPB(data, msg); err != nil { + return fmt.Errorf("decode protobuf: %v", err) + } + return nil } -) + if isPBMsg { + // only decode into JSON of a protobuf message if the type + // explicitly implements json.Unmarshaler + if _, ok := i.(json.Unmarshaler); !ok { + return errors.New("cannot decode json payload into protobuf object") + } + } + if err := json.Unmarshal(data, i); err != nil { + return fmt.Errorf("decode json: %v", err) + } + return nil +} var magicBytes = []byte{0x6b, 0x38, 0x73, 0x00} -func unmarshalPB(b []byte, obj interface{}) error { - message, ok := obj.(proto.Message) - if !ok { - return fmt.Errorf("expected obj of type proto.Message, got %T", obj) - } +func unmarshalPB(b []byte, msg proto.Message) error { if len(b) < len(magicBytes) { return errors.New("payload is not a kubernetes protobuf object") } @@ -56,7 +71,7 @@ func unmarshalPB(b []byte, obj interface{}) error { if err := u.Unmarshal(b[len(magicBytes):]); err != nil { return fmt.Errorf("unmarshal unknown: %v", err) } - return proto.Unmarshal(u.Raw, message) + return proto.Unmarshal(u.Raw, msg) } func marshalPB(obj interface{}) ([]byte, error) { diff --git a/discovery.go b/discovery.go index b2713cb..a219dae 100644 --- a/discovery.go +++ b/discovery.go @@ -4,7 +4,7 @@ import ( "context" "path" - "github.com/ericchiang/k8s/api/unversioned" + metav1 "github.com/ericchiang/k8s/apis/meta/v1" ) type Version struct { @@ -19,45 +19,48 @@ type Version struct { Platform string `json:"platform"` } -func (c *Client) Discovery() *Discovery { - return &Discovery{c} -} - // Discovery is a client used to determine the API version and supported // resources of the server. type Discovery struct { client *Client } +func NewDiscoveryClient(c *Client) *Discovery { + return &Discovery{c} +} + +func (d *Discovery) get(ctx context.Context, path string, resp interface{}) error { + return d.client.do(ctx, "GET", urlForPath(d.client.Endpoint, path), nil, resp) +} + func (d *Discovery) Version(ctx context.Context) (*Version, error) { var v Version - if err := d.client.get(ctx, jsonCodec, d.client.urlForPath("version"), &v); err != nil { + if err := d.get(ctx, "version", &v); err != nil { return nil, err } return &v, nil } -func (d *Discovery) APIGroups(ctx context.Context) (*unversioned.APIGroupList, error) { - var groups unversioned.APIGroupList - if err := d.client.get(ctx, pbCodec, d.client.urlForPath("apis"), &groups); err != nil { +func (d *Discovery) APIGroups(ctx context.Context) (*metav1.APIGroupList, error) { + var groups metav1.APIGroupList + if err := d.get(ctx, "apis", &groups); err != nil { return nil, err } return &groups, nil } -func (d *Discovery) APIGroup(ctx context.Context, name string) (*unversioned.APIGroup, error) { - var group unversioned.APIGroup - if err := d.client.get(ctx, pbCodec, d.client.urlForPath(path.Join("apis", name)), &group); err != nil { +func (d *Discovery) APIGroup(ctx context.Context, name string) (*metav1.APIGroup, error) { + var group metav1.APIGroup + if err := d.get(ctx, path.Join("apis", name), &group); err != nil { return nil, err } return &group, nil } -func (d *Discovery) APIResources(ctx context.Context, groupName, groupVersion string) (*unversioned.APIResourceList, error) { - var list unversioned.APIResourceList - if err := d.client.get(ctx, pbCodec, d.client.urlForPath(path.Join("apis", groupName, groupVersion)), &list); err != nil { +func (d *Discovery) APIResources(ctx context.Context, groupName, groupVersion string) (*metav1.APIResourceList, error) { + var list metav1.APIResourceList + if err := d.get(ctx, path.Join("apis", groupName, groupVersion), &list); err != nil { return nil, err } return &list, nil - } diff --git a/discovery_test.go b/discovery_test.go index 562a98f..a06b1a6 100644 --- a/discovery_test.go +++ b/discovery_test.go @@ -1,12 +1,14 @@ -package k8s +package k8s_test import ( "context" "testing" + + "github.com/ericchiang/k8s" ) func TestDiscovery(t *testing.T) { - client := newTestClient(t).Discovery() + client := k8s.NewDiscoveryClient(newTestClient(t)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/api-errors.go b/examples/api-errors.go index ed7b82c..d58244b 100644 --- a/examples/api-errors.go +++ b/examples/api-errors.go @@ -8,7 +8,7 @@ import ( "net/http" "github.com/ericchiang/k8s" - "github.com/ericchiang/k8s/api/v1" + "github.com/ericchiang/k8s/apis/core/v1" metav1 "github.com/ericchiang/k8s/apis/meta/v1" ) @@ -24,7 +24,10 @@ func createConfigMap(client *k8s.Client, name string, values map[string]string) Data: values, } - _, err := client.CoreV1().CreateConfigMap(context.TODO(), cm) + err := client.Create(context.Background(), cm) + if err == nil { + return err + } // If an HTTP error was returned by the API server, it will be of type // *k8s.APIError. This can be used to inspect the status code. diff --git a/examples/create-resource.go b/examples/create-resource.go index f454184..4c80e0e 100644 --- a/examples/create-resource.go +++ b/examples/create-resource.go @@ -6,7 +6,7 @@ import ( "context" "github.com/ericchiang/k8s" - "github.com/ericchiang/k8s/api/v1" + "github.com/ericchiang/k8s/apis/core/v1" metav1 "github.com/ericchiang/k8s/apis/meta/v1" ) @@ -19,6 +19,5 @@ func createConfigMap(client *k8s.Client, name string, values map[string]string) Data: values, } // Will return the created configmap as well. - _, err := client.CoreV1().CreateConfigMap(context.TODO(), cm) - return err + return client.Create(context.TODO(), cm) } diff --git a/examples/custom-resources.go b/examples/custom-resources.go new file mode 100644 index 0000000..e89a929 --- /dev/null +++ b/examples/custom-resources.go @@ -0,0 +1,57 @@ +// +build ignore + +package customresources + +import ( + "context" + "fmt" + + "github.com/ericchiang/k8s" + metav1 "github.com/ericchiang/k8s/apis/meta/v1" +) + +func init() { + k8s.Register("resource.example.com", "v1", "myresource", true, &MyResource{}) + k8s.RegisterList("resource.example.com", "v1", "myresource", true, &MyResourceList{}) +} + +type MyResource struct { + Metadata *metav1.ObjectMeta `json:"metadata"` + Foo string `json:"foo"` + Bar int `json:"bar"` +} + +func (m *MyResource) GetMetadata() *metav1.ObjectMeta { + return m.Metadata +} + +type MyResourceList struct { + Metadata *metav1.ListMeta `json:"metadata"` + Items []MyResource `json:"items"` +} + +func (m *MyResourceList) GetMetadata() *metav1.ListMeta { + return m.Metadata +} + +func do(ctx context.Context, client *k8s.Client, namespace string) error { + r := &MyResource{ + Metadata: &metav1.ObjectMeta{ + Name: k8s.String("my-custom-resource"), + Namespace: &namespace, + }, + Foo: "hello, world!", + Bar: 42, + } + if err := client.Create(ctx, r); err != nil { + return fmt.Errorf("create: %v", err) + } + r.Bar = -8 + if err := client.Update(ctx, r); err != nil { + return fmt.Errorf("update: %v", err) + } + if err := client.Delete(ctx, r); err != nil { + return fmt.Errorf("delete: %v", err) + } + return nil +} diff --git a/examples/in-cluster-client.go b/examples/in-cluster-client.go index 31a8a36..c388827 100644 --- a/examples/in-cluster-client.go +++ b/examples/in-cluster-client.go @@ -8,6 +8,7 @@ import ( "log" "github.com/ericchiang/k8s" + corev1 "github.com/ericchiang/k8s/apis/core/v1" ) func listNodes() { @@ -16,8 +17,8 @@ func listNodes() { log.Fatal(err) } - nodes, err := client.CoreV1().ListNodes(context.Background()) - if err != nil { + var nodes corev1.NodeList + if err := client.List(context.Background(), "", &nodes); err != nil { log.Fatal(err) } for _, node := range nodes.Items { diff --git a/gen.go b/gen.go deleted file mode 100644 index 1cfb06c..0000000 --- a/gen.go +++ /dev/null @@ -1,382 +0,0 @@ -// +build ignore - -package main - -import ( - "bytes" - "errors" - "fmt" - "go/types" - "io/ioutil" - "os" - "os/exec" - "path" - "sort" - "strings" - "text/template" - - "golang.org/x/tools/go/loader" -) - -func main() { - if err := load(); err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(2) - } -} - -func isInterface(obj interface{}) (*types.Interface, bool) { - switch obj := obj.(type) { - case *types.TypeName: - return isInterface(obj.Type()) - case *types.Named: - return isInterface(obj.Underlying()) - case *types.Interface: - return obj, true - default: - return nil, false - } -} - -type Resource struct { - Name string - Namespaced bool - HasList bool - Pluralized string -} - -type byName []Resource - -func (n byName) Len() int { return len(n) } -func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } -func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } - -type Package struct { - Name string - APIGroup string - APIVersion string - ImportPath string - ImportName string - Resources []Resource -} - -type byGroup []Package - -func (r byGroup) Len() int { return len(r) } -func (r byGroup) Swap(i, j int) { r[i], r[j] = r[j], r[i] } - -func (r byGroup) Less(i, j int) bool { - if r[i].APIGroup != r[j].APIGroup { - return r[i].APIGroup < r[j].APIGroup - } - return r[i].APIVersion < r[j].APIVersion -} - -// Incorrect but this is basically what Kubernetes does. -func pluralize(s string) string { - switch { - case strings.HasSuffix(s, "points"): - // NOTE: the k8s "endpoints" resource is already pluralized - return s - case strings.HasSuffix(s, "s"): - return s + "es" - case strings.HasSuffix(s, "y"): - return s[:len(s)-1] + "ies" - default: - return s + "s" - } -} - -var tmpl = template.Must(template.New("").Funcs(template.FuncMap{ - "pluralize": pluralize, -}).Parse(` -// {{ .Name }} returns a client for interacting with the {{ .APIGroup }}/{{ .APIVersion }} API group. -func (c *Client) {{ .Name }}() *{{ .Name }} { - return &{{ .Name }}{c} -} - -// {{ .Name }} is a client for interacting with the {{ .APIGroup }}/{{ .APIVersion }} API group. -type {{ .Name }} struct { - client *Client -} -{{ range $i, $r := .Resources }} -func (c *{{ $.Name }}) Create{{ $r.Name }}(ctx context.Context, obj *{{ $.ImportName }}.{{ $r.Name }}) (*{{ $.ImportName }}.{{ $r.Name }}, error) { - md := obj.GetMetadata() - if md.Name != nil && *md.Name == "" { - return nil, fmt.Errorf("no name for given object") - } - - ns := "" - if md.Namespace != nil { - ns = *md.Namespace - } - if !{{ $r.Namespaced }} && ns != ""{ - return nil, fmt.Errorf("resource isn't namespaced") - } - - if {{ $r.Namespaced }} { - if ns == "" { - return nil, fmt.Errorf("no resource namespace provided") - } - md.Namespace = &ns - } - url := c.client.urlFor("{{ $.APIGroup }}", "{{ $.APIVersion }}", ns, "{{ $r.Pluralized }}", "") - resp := new({{ $.ImportName }}.{{ $r.Name }}) - err := c.client.create(ctx, pbCodec, "POST", url, obj, resp) - if err != nil { - return nil, err - } - return resp, nil -} - -func (c *{{ $.Name }}) Update{{ $r.Name }}(ctx context.Context, obj *{{ $.ImportName }}.{{ $r.Name }}) (*{{ $.ImportName }}.{{ $r.Name }}, error) { - md := obj.GetMetadata() - if md.Name != nil && *md.Name == "" { - return nil, fmt.Errorf("no name for given object") - } - - ns := "" - if md.Namespace != nil { - ns = *md.Namespace - } - if !{{ $r.Namespaced }} && ns != "" { - return nil, fmt.Errorf("resource isn't namespaced") - } - - if {{ $r.Namespaced }} { - if ns == "" { - return nil, fmt.Errorf("no resource namespace provided") - } - md.Namespace = &ns - } - url := c.client.urlFor("{{ $.APIGroup }}", "{{ $.APIVersion }}", *md.Namespace, "{{ $r.Pluralized }}", *md.Name) - resp := new({{ $.ImportName }}.{{ $r.Name }}) - err := c.client.create(ctx, pbCodec, "PUT", url, obj, resp) - if err != nil { - return nil, err - } - return resp, nil -} - -func (c *{{ $.Name }}) Delete{{ $r.Name }}(ctx context.Context, name string{{ if $r.Namespaced }}, namespace string{{ end }}) error { - if name == "" { - return fmt.Errorf("create: no name for given object") - } - url := c.client.urlFor("{{ $.APIGroup }}", "{{ $.APIVersion }}", {{ if $r.Namespaced }}namespace{{ else }}AllNamespaces{{ end }}, "{{ $r.Pluralized }}", name) - return c.client.delete(ctx, pbCodec, url) -} - -func (c *{{ $.Name }}) Get{{ $r.Name }}(ctx context.Context, name{{ if $r.Namespaced }}, namespace{{ end }} string) (*{{ $.ImportName }}.{{ $r.Name }}, error) { - if name == "" { - return nil, fmt.Errorf("create: no name for given object") - } - url := c.client.urlFor("{{ $.APIGroup }}", "{{ $.APIVersion }}", {{ if $r.Namespaced }}namespace{{ else }}AllNamespaces{{ end }}, "{{ $r.Pluralized }}", name) - resp := new({{ $.ImportName }}.{{ $r.Name }}) - if err := c.client.get(ctx, pbCodec, url, resp); err != nil { - return nil, err - } - return resp, nil -} - -{{- if $r.HasList }} - -type {{ $.Name }}{{ $r.Name }}Watcher struct { - watcher *watcher -} - -func (w *{{ $.Name }}{{ $r.Name }}Watcher) Next() (*versioned.Event, *{{ $.ImportName }}.{{ $r.Name }}, error) { - event, unknown, err := w.watcher.next() - if err != nil { - return nil, nil, err - } - resp := new({{ $.ImportName }}.{{ $r.Name }}) - if err := proto.Unmarshal(unknown.Raw, resp); err != nil { - return nil, nil, err - } - return event, resp, nil -} - -func (w *{{ $.Name }}{{ $r.Name }}Watcher) Close() error { - return w.watcher.Close() -} - -func (c *{{ $.Name }}) Watch{{ $r.Name | pluralize }}(ctx context.Context{{ if $r.Namespaced }}, namespace string{{ end }}, options ...Option) (*{{ $.Name }}{{ $r.Name }}Watcher, error) { - url := c.client.urlFor("{{ $.APIGroup }}", "{{ $.APIVersion }}", {{ if $r.Namespaced }}namespace{{ else }}AllNamespaces{{ end }}, "{{ $r.Pluralized }}", "", options...) - watcher, err := c.client.watch(ctx, url) - if err != nil { - return nil, err - } - return &{{ $.Name }}{{ $r.Name }}Watcher{watcher}, nil -} - -func (c *{{ $.Name }}) List{{ $r.Name | pluralize }}(ctx context.Context{{ if $r.Namespaced }}, namespace string{{ end }}, options ...Option) (*{{ $.ImportName }}.{{ $r.Name }}List, error) { - url := c.client.urlFor("{{ $.APIGroup }}", "{{ $.APIVersion }}", {{ if $r.Namespaced }}namespace{{ else }}AllNamespaces{{ end }}, "{{ $r.Pluralized }}", "", options...) - resp := new({{ $.ImportName }}.{{ $r.Name }}List) - if err := c.client.get(ctx, pbCodec, url, resp); err != nil { - return nil, err - } - return resp, nil -}{{ end }} -{{ end }} -`)) - -var ( - apiGroupName = map[string]string{ - "authentication": "authentication.k8s.io", - "authorization": "authorization.k8s.io", - "certificates": "certificates.k8s.io", - "rbac": "rbac.authorization.k8s.io", - "storage": "storage.k8s.io", - } - notNamespaced = map[string]bool{ - "ClusterRole": true, - "ClusterRoleBinding": true, - - "ComponentStatus": true, - "Node": true, - "Namespace": true, - "PersistentVolume": true, - - "PodSecurityPolicy": true, - "ThirdPartyResource": true, - - "CertificateSigningRequest": true, - - "TokenReview": true, - - "SubjectAccessReview": true, - "SelfSubjectAccessReview": true, - - "ImageReview": true, - - "StorageClass": true, - } -) - -func clientName(apiGroup, apiVersion string) string { - switch apiGroup { - case "": - apiGroup = "Core" - case "rbac": - apiGroup = "RBAC" - default: - apiGroup = strings.Title(apiGroup) - } - r := strings.NewReplacer("alpha", "Alpha", "beta", "Beta") - return apiGroup + r.Replace(strings.Title(apiVersion)) -} - -func load() error { - out, err := exec.Command("go", "list", "./...").CombinedOutput() - if err != nil { - return fmt.Errorf("go list: %v %s", err, out) - } - - var conf loader.Config - if _, err := conf.FromArgs(strings.Fields(string(out)), false); err != nil { - return fmt.Errorf("from args: %v", err) - } - - prog, err := conf.Load() - if err != nil { - return fmt.Errorf("load: %v", err) - } - thisPkg, ok := prog.Imported["github.com/ericchiang/k8s"] - if !ok { - return errors.New("could not find this package") - } - - // Types defined in tpr.go. It's hacky, but to "load" interfaces as their - // go/types equilvalent, we either have to: - // - // * Define them in code somewhere (what we're doing here). - // * Manually construct them using go/types (blah). - // * Parse them from an inlined string (doesn't work in combination with other pkgs). - // - var interfaces []*types.Interface - for _, s := range []string{"object", "after16Object"} { - obj := thisPkg.Pkg.Scope().Lookup(s) - if obj == nil { - return errors.New("failed to lookup object interface") - } - intr, ok := isInterface(obj) - if !ok { - return errors.New("failed to convert to interface") - } - interfaces = append(interfaces, intr) - } - - var pkgs []Package - for name, pkgInfo := range prog.Imported { - pkg := Package{ - APIVersion: path.Base(name), - APIGroup: path.Base(path.Dir(name)), - ImportPath: name, - } - pkg.ImportName = pkg.APIGroup + pkg.APIVersion - - if pkg.APIGroup == "api" { - pkg.APIGroup = "" - } - - pkg.Name = clientName(pkg.APIGroup, pkg.APIVersion) - if name, ok := apiGroupName[pkg.APIGroup]; ok { - pkg.APIGroup = name - } - - for _, obj := range pkgInfo.Defs { - tn, ok := obj.(*types.TypeName) - if !ok { - continue - } - impl := false - for _, intr := range interfaces { - impl = impl || types.Implements(types.NewPointer(tn.Type()), intr) - } - if !impl { - continue - } - if tn.Name() == "JobTemplateSpec" { - continue - } - - pkg.Resources = append(pkg.Resources, Resource{ - Name: tn.Name(), - Pluralized: pluralize(strings.ToLower(tn.Name())), - HasList: pkgInfo.Pkg.Scope().Lookup(tn.Name()+"List") != nil, - Namespaced: !notNamespaced[tn.Name()], - }) - } - pkgs = append(pkgs, pkg) - } - - sort.Sort(byGroup(pkgs)) - - buff := new(bytes.Buffer) - buff.WriteString("package k8s\n\n") - buff.WriteString("import (\n") - buff.WriteString("\t\"context\"\n") - buff.WriteString("\t\"fmt\"\n\n") - for _, pkg := range pkgs { - if len(pkg.Resources) == 0 { - continue - } - fmt.Fprintf(buff, "\t%s \"%s\"\n", pkg.ImportName, pkg.ImportPath) - } - fmt.Fprintf(buff, "\t%q\n", "github.com/ericchiang/k8s/watch/versioned") - fmt.Fprintf(buff, "\t%q\n", "github.com/golang/protobuf/proto") - buff.WriteString(")\n") - - for _, pkg := range pkgs { - sort.Sort(byName(pkg.Resources)) - for _, resource := range pkg.Resources { - fmt.Println(pkg.APIGroup, pkg.APIVersion, resource.Name) - } - if len(pkg.Resources) != 0 { - if err := tmpl.Execute(buff, pkg); err != nil { - return fmt.Errorf("execute: %v", err) - } - } - } - return ioutil.WriteFile("types.go", buff.Bytes(), 0644) -} diff --git a/gen.sh b/gen.sh deleted file mode 100755 index af587ec..0000000 --- a/gen.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash - -set -ex - -# Clean up any existing build. -rm -rf assets/k8s.io -mkdir -p assets/k8s.io/kubernetes - -VERSIONS=( "1.4.7" "1.5.1" "1.6.0-rc.1" ) - -for VERSION in ${VERSIONS[@]}; do - if [ ! -f assets/v${VERSION}.zip ]; then - wget https://github.com/kubernetes/kubernetes/archive/v${VERSION}.zip -O assets/v${VERSION}.zip - fi - - # Copy source tree to assets/k8s.io/kubernetes. Newer versions overwrite existing ones. - unzip -q assets/v${VERSION}.zip -d assets/ - cp -r assets/kubernetes-${VERSION}/* assets/k8s.io/kubernetes - rm -rf assets/kubernetes-${VERSION} -done - -# Rewrite API machinery files to their equivalent. -apimachinery=assets/k8s.io/kubernetes/staging/src/k8s.io/apimachinery/ -for file in $( find $apimachinery -type f -name '*.proto' ); do - path=assets/k8s.io/kubernetes/${file#$apimachinery} - mkdir -p $(dirname $path) - mv $file $path -done - -# Remove any existing generated code. -rm -rf api apis config.go runtime util types.go watch - -# Generate Go code from proto definitions. -PKG=$PWD -cd assets - -protobuf=$( find k8s.io/kubernetes/pkg/{api,apis,util,runtime,watch} -name '*.proto' ) - -# Remote this ununused import: -# https://github.com/kubernetes/kubernetes/blob/v1.6.0-rc.1/pkg/api/v1/generated.proto#L29 -sed -i '/"k8s\.io\/apiserver\/pkg\/apis\/example\/v1\/generated.proto"/d' $protobuf - -# Rewrite all of the API machineary out of staging. -sed -i 's|"k8s.io/apimachinery/|"k8s.io/kubernetes/|g' $protobuf -sed -i 's/k8s\.io.apimachinery/k8s\.io.kubernetes/g' $protobuf - -for file in $protobuf; do - echo $file - # Generate protoc definitions at the base of this repo. - protoc --gofast_out=$PKG $file -done - -cd - - -mv k8s.io/kubernetes/pkg/* . -rm -rf k8s.io - -# Copy kubeconfig structs. -client_dir="client/unversioned/clientcmd/api/v1" -cp assets/k8s.io/kubernetes/pkg/${client_dir}/types.go config.go -sed -i 's|package v1|package k8s|g' config.go - -# Rewrite imports for the generated fiels. -sed -i 's|"k8s.io/kubernetes/pkg|"github.com/ericchiang/k8s|g' $( find {api,apis,config.go,util,runtime,watch} -name '*.go' ) -sed -i 's|"k8s.io.kubernetes.pkg.|"github.com/ericchiang.k8s.|g' $( find {api,apis,config.go,util,runtime,watch} -name '*.go' ) - -# Clean up assets. -rm -rf assets/k8s.io - -# Generate HTTP clients from Go structs. -go run gen.go - -# Fix JSON marshaling for types need by third party resources. -cat << EOF >> api/unversioned/time.go -package unversioned - -import ( - "encoding/json" - "time" -) - -// JSON marshaling logic for the Time type. Need to make -// third party resources JSON work. - -func (t Time) MarshalJSON() ([]byte, error) { - var seconds, nanos int64 - if t.Seconds != nil { - seconds = *t.Seconds - } - if t.Nanos != nil { - nanos = int64(*t.Nanos) - } - return json.Marshal(time.Unix(seconds, nanos)) -} - -func (t *Time) UnmarshalJSON(p []byte) error { - var t1 time.Time - if err := json.Unmarshal(p, &t1); err != nil { - return err - } - seconds := t1.Unix() - nanos := int32(t1.UnixNano()) - t.Seconds = &seconds - t.Nanos = &nanos - return nil -} -EOF -gofmt -w api/unversioned/time.go -cp api/unversioned/time.go apis/meta/v1 -sed -i 's|package unversioned|package v1|g' apis/meta/v1/time.go diff --git a/resource.go b/resource.go new file mode 100644 index 0000000..3277faf --- /dev/null +++ b/resource.go @@ -0,0 +1,168 @@ +package k8s + +import ( + "errors" + "fmt" + "net/url" + "path" + "reflect" + "strings" + + metav1 "github.com/ericchiang/k8s/apis/meta/v1" +) + +type resourceType struct { + apiGroup string + apiVersion string + name string + namespaced bool +} + +var ( + resources = map[reflect.Type]resourceType{} + resourceLists = map[reflect.Type]resourceType{} +) + +// Resource is a Kubernetes resource, such as a Node or Pod. +type Resource interface { + GetMetadata() *metav1.ObjectMeta +} + +// Resource is list of common Kubernetes resources, such as a NodeList or +// PodList. +type ResourceList interface { + GetMetadata() *metav1.ListMeta +} + +func Register(apiGroup, apiVersion, name string, namespaced bool, r Resource) { + rt := reflect.TypeOf(r) + if _, ok := resources[rt]; ok { + panic(fmt.Sprintf("resource registered twice %T", r)) + } + resources[rt] = resourceType{apiGroup, apiVersion, name, namespaced} +} + +func RegisterList(apiGroup, apiVersion, name string, namespaced bool, l ResourceList) { + rt := reflect.TypeOf(l) + if _, ok := resources[rt]; ok { + panic(fmt.Sprintf("resource registered twice %T", l)) + } + resourceLists[rt] = resourceType{apiGroup, apiVersion, name, namespaced} +} + +func urlFor(endpoint, apiGroup, apiVersion, namespace, resource, name string, options ...Option) string { + basePath := "apis/" + if apiGroup == "" { + basePath = "api/" + } + + var p string + if namespace != "" { + p = path.Join(basePath, apiGroup, apiVersion, "namespaces", namespace, resource, name) + } else { + p = path.Join(basePath, apiGroup, apiVersion, resource, name) + } + e := "" + if strings.HasSuffix(endpoint, "/") { + e = endpoint + p + } else { + e = endpoint + "/" + p + } + if len(options) == 0 { + return e + } + + v := url.Values{} + for _, option := range options { + key, val := option.queryParam() + v.Set(key, val) + } + return e + "?" + v.Encode() +} + +func urlForPath(endpoint, path string) string { + if strings.HasPrefix(path, "/") { + path = path[1:] + } + if strings.HasSuffix(endpoint, "/") { + return endpoint + path + } + return endpoint + "/" + path +} + +func resourceURL(endpoint string, r Resource, withName bool, options ...Option) (string, error) { + t, ok := resources[reflect.TypeOf(r)] + if !ok { + return "", fmt.Errorf("unregistered type %T", r) + } + meta := r.GetMetadata() + if meta == nil { + return "", errors.New("resource has no object meta") + } + switch { + case t.namespaced && (meta.Namespace == nil || *meta.Namespace == ""): + return "", errors.New("no resource namespace provided") + case !t.namespaced && (meta.Namespace != nil && *meta.Namespace != ""): + return "", errors.New("resource not namespaced") + case withName && (meta.Name == nil || *meta.Name == ""): + return "", errors.New("no resource name provided") + } + name := "" + if withName { + name = *meta.Name + } + namespace := "" + if t.namespaced { + namespace = *meta.Namespace + } + + return urlFor(endpoint, t.apiGroup, t.apiVersion, namespace, t.name, name, options...), nil +} + +func resourceGetURL(endpoint, namespace, name string, r Resource, options ...Option) (string, error) { + t, ok := resources[reflect.TypeOf(r)] + if !ok { + return "", fmt.Errorf("unregistered type %T", r) + } + + if !t.namespaced && namespace != "" { + return "", fmt.Errorf("type not namespaced") + } + if t.namespaced && namespace == "" { + return "", fmt.Errorf("no namespace provided") + } + + return urlFor(endpoint, t.apiGroup, t.apiVersion, namespace, t.name, name, options...), nil +} + +func resourceListURL(endpoint, namespace string, r ResourceList, options ...Option) (string, error) { + t, ok := resourceLists[reflect.TypeOf(r)] + if !ok { + return "", fmt.Errorf("unregistered type %T", r) + } + + if !t.namespaced && namespace != "" { + return "", fmt.Errorf("type not namespaced") + } + + return urlFor(endpoint, t.apiGroup, t.apiVersion, namespace, t.name, "", options...), nil +} + +func resourceWatchURL(endpoint, namespace string, r Resource, options ...Option) (string, error) { + t, ok := resources[reflect.TypeOf(r)] + if !ok { + return "", fmt.Errorf("unregistered type %T", r) + } + + if !t.namespaced && namespace != "" { + return "", fmt.Errorf("type not namespaced") + } + + url := urlFor(endpoint, t.apiGroup, t.apiVersion, namespace, t.name, "", options...) + if strings.Contains(url, "?") { + url = url + "&watch=true" + } else { + url = url + "?watch=true" + } + return url, nil +} diff --git a/resource_test.go b/resource_test.go new file mode 100644 index 0000000..f86267a --- /dev/null +++ b/resource_test.go @@ -0,0 +1,119 @@ +package k8s + +import ( + "testing" + + metav1 "github.com/ericchiang/k8s/apis/meta/v1" +) + +// Redefine types since all API groups import "github.com/ericchiang/k8s" +// We can't use them here because it'll create a circular import cycle. + +type Pod struct { + Metadata *metav1.ObjectMeta +} + +type PodList struct { + Metadata *metav1.ListMeta +} + +func (p *Pod) GetMetadata() *metav1.ObjectMeta { return p.Metadata } +func (p *PodList) GetMetadata() *metav1.ListMeta { return p.Metadata } + +type Deployment struct { + Metadata *metav1.ObjectMeta +} + +type DeploymentList struct { + Metadata *metav1.ListMeta +} + +func (p *Deployment) GetMetadata() *metav1.ObjectMeta { return p.Metadata } +func (p *DeploymentList) GetMetadata() *metav1.ListMeta { return p.Metadata } + +type ClusterRole struct { + Metadata *metav1.ObjectMeta +} + +type ClusterRoleList struct { + Metadata *metav1.ListMeta +} + +func (p *ClusterRole) GetMetadata() *metav1.ObjectMeta { return p.Metadata } +func (p *ClusterRoleList) GetMetadata() *metav1.ListMeta { return p.Metadata } + +func init() { + Register("", "v1", "pods", true, &Pod{}) + RegisterList("", "v1", "pods", true, &PodList{}) + + Register("apps", "v1beta2", "deployments", true, &Deployment{}) + RegisterList("apps", "v1beta2", "deployments", true, &DeploymentList{}) + + Register("rbac.authorization.k8s.io", "v1", "clusterroles", false, &ClusterRole{}) + RegisterList("rbac.authorization.k8s.io", "v1", "clusterroles", false, &ClusterRoleList{}) +} + +func TestResourceURL(t *testing.T) { + tests := []struct { + name string + endpoint string + resource Resource + withName bool + options []Option + want string + wantErr bool + }{ + { + name: "pod", + endpoint: "https://example.com", + resource: &Pod{ + Metadata: &metav1.ObjectMeta{ + Namespace: String("my-namespace"), + Name: String("my-pod"), + }, + }, + want: "https://example.com/api/v1/namespaces/my-namespace/pods", + }, + { + name: "deployment", + endpoint: "https://example.com", + resource: &Deployment{ + Metadata: &metav1.ObjectMeta{ + Namespace: String("my-namespace"), + Name: String("my-deployment"), + }, + }, + want: "https://example.com/apis/apps/v1beta2/namespaces/my-namespace/deployments", + }, + { + name: "deployment-with-name", + endpoint: "https://example.com", + resource: &Deployment{ + Metadata: &metav1.ObjectMeta{ + Namespace: String("my-namespace"), + Name: String("my-deployment"), + }, + }, + withName: true, + want: "https://example.com/apis/apps/v1beta2/namespaces/my-namespace/deployments/my-deployment", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := resourceURL(test.endpoint, test.resource, test.withName, test.options...) + if err != nil { + if test.wantErr { + return + } + t.Fatalf("constructing resource URL: %v", err) + } + if test.wantErr { + t.Fatal("expected error") + } + if test.want != got { + t.Errorf("wanted=%q, got=%q", test.want, got) + } + }) + } +} diff --git a/scripts/generate.sh b/scripts/generate.sh new file mode 100755 index 0000000..b0a4545 --- /dev/null +++ b/scripts/generate.sh @@ -0,0 +1,54 @@ +#!/bin/bash -e + +TEMPDIR=$(mktemp -d) +mkdir -p $TEMPDIR/src/github.com/golang +ln -s $PWD/_output/src/github.com/golang/protobuf $TEMPDIR/src/github.com/golang/protobuf +function cleanup { + unlink $TEMPDIR/src/github.com/golang/protobuf + rm -rf $TEMPDIR +} +trap cleanup EXIT + +# Ensure we're using protoc and gomvpkg that this repo downloads. +export PATH=$PWD/_output/bin:$PATH + +# Copy all .proto files from Kubernetes into a temporary directory. +REPOS=( "apimachinery" "api" "apiextensions-apiserver" "kube-aggregator" ) +for REPO in "${REPOS[@]}"; do + SOURCE=$PWD/_output/kubernetes/staging/src/k8s.io/$REPO + TARGET=$TEMPDIR/src/k8s.io + mkdir -p $TARGET + rsync -a --prune-empty-dirs --include '*/' --include '*.proto' --exclude '*' $SOURCE $TARGET +done + +# Remove API groups that aren't actually real +rm -r $TEMPDIR/src/k8s.io/apimachinery/pkg/apis/testapigroup + +cd $TEMPDIR/src +for FILE in $( find . -type f ); do + protoc --gofast_out=. $FILE +done +rm $( find . -type f -name '*.proto' ); +cd - + +export GOPATH=$TEMPDIR +function mvpkg { + FROM="k8s.io/$1" + TO="github.com/ericchiang/k8s/$2" + mkdir -p "$GOPATH/src/$(dirname $TO)" + echo "gompvpkg -from=$FROM -to=$TO" + gomvpkg -from=$FROM -to=$TO +} + +mvpkg apiextensions-apiserver/pkg/apis/apiextensions/v1beta1 apis/apiextensions/v1beta1 +mvpkg apimachinery/pkg/api/resource apis/resource +mvpkg apimachinery/pkg/apis/meta apis/meta +mvpkg apimachinery/pkg/runtime runtime +mvpkg apimachinery/pkg/util util +for DIR in $( ls ${TEMPDIR}/src/k8s.io/api/ ); do + mvpkg api/$DIR apis/$DIR +done +mvpkg kube-aggregator/pkg/apis/apiregistration apis/apiregistration + +rm -rf api apis runtime util +mv $TEMPDIR/src/github.com/ericchiang/k8s/* . diff --git a/scripts/get-protoc.sh b/scripts/get-protoc.sh new file mode 100755 index 0000000..3902868 --- /dev/null +++ b/scripts/get-protoc.sh @@ -0,0 +1,9 @@ +#!/bin/bash -e + +OS=$(uname) +if [ "$OS" == "Darwin" ]; then + OS="osx" +fi + +curl -L -o _output/protoc.zip https://github.com/google/protobuf/releases/download/v3.5.1/protoc-3.5.1-${OS}-x86_64.zip +unzip _output/protoc.zip bin/protoc -d _output diff --git a/scripts/git-diff.sh b/scripts/git-diff.sh new file mode 100755 index 0000000..302ac2c --- /dev/null +++ b/scripts/git-diff.sh @@ -0,0 +1,7 @@ +#!/bin/bash -e + +DIFF=$( git diff . ) +if [ "$DIFF" != "" ]; then + echo "$DIFF" >&2 + exit 1 +fi diff --git a/scripts/go-install.sh b/scripts/go-install.sh new file mode 100755 index 0000000..e089b03 --- /dev/null +++ b/scripts/go-install.sh @@ -0,0 +1,16 @@ +#!/bin/bash -e + +function usage { + >&2 echo "./go-install.sh [repo] [repo import path] [tool import path] [rev]" +} + +REPO=$1 +REPO_ROOT=$2 +TOOL=$3 +REV=$4 + +git clone $REPO _output/src/$REPO_ROOT +cd _output/src/$REPO_ROOT +git checkout $REV +cd - +GOPATH=$PWD/_output GOBIN=$PWD/_output/bin go install -v $TOOL diff --git a/scripts/register.go b/scripts/register.go new file mode 100644 index 0000000..0f42ebb --- /dev/null +++ b/scripts/register.go @@ -0,0 +1,341 @@ +// +build ignore + +package main + +import ( + "bytes" + "go/format" + "html/template" + "io/ioutil" + "log" + "path/filepath" + "strings" +) + +type Resource struct { + GoType string + // Plural name of the resource. If empty, the GoType lowercased + "s". + Name string + Flags uint8 +} + +const ( + // Is the resource cluster scoped (e.g. "nodes")? + NotNamespaced uint8 = 1 << iota + // Many "review" resources can be created but not listed + NoList uint8 = 1 << iota +) + +type APIGroup struct { + Package string + Group string + Versions map[string][]Resource +} + +func init() { + for _, group := range apiGroups { + for _, resources := range group.Versions { + for i, r := range resources { + if r.Name == "" { + r.Name = strings.ToLower(r.GoType) + "s" + } + resources[i] = r + } + } + } +} + +var apiGroups = []APIGroup{ + { + Package: "admissionregistration", + Group: "admissionregistration.k8s.io", + Versions: map[string][]Resource{ + "v1beta1": []Resource{ + {"MutatingWebhookConfiguration", "", NotNamespaced}, + {"ValidatingWebhookConfiguration", "", NotNamespaced}, + }, + "v1alpha1": []Resource{ + {"InitializerConfiguration", "", NotNamespaced}, + }, + }, + }, + { + Package: "apiextensions", + Group: "apiextensions.k8s.io", + Versions: map[string][]Resource{ + "v1beta1": []Resource{ + {"CustomResourceDefinition", "", NotNamespaced}, + }, + }, + }, + { + Package: "apps", + Group: "apps", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"ControllerRevision", "", 0}, + {"DaemonSet", "", 0}, + {"Deployment", "", 0}, + {"ReplicaSet", "", 0}, + {"StatefulSet", "", 0}, + }, + "v1beta2": []Resource{ + {"ControllerRevision", "", 0}, + {"DaemonSet", "", 0}, + {"Deployment", "", 0}, + {"ReplicaSet", "", 0}, + {"StatefulSet", "", 0}, + }, + "v1beta1": []Resource{ + {"ControllerRevision", "", 0}, + {"Deployment", "", 0}, + {"StatefulSet", "", 0}, + }, + }, + }, + { + Package: "authentication", + Group: "authentication.k8s.io", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"TokenReview", "", NotNamespaced | NoList}, + }, + "v1beta1": []Resource{ + {"TokenReview", "", NotNamespaced | NoList}, + }, + }, + }, + { + Package: "authorization", + Group: "authorization.k8s.io", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"LocalSubjectAccessReview", "", NoList}, + {"SelfSubjectAccessReview", "", NotNamespaced | NoList}, + {"SelfSubjectRulesReview", "", NotNamespaced | NoList}, + {"SubjectAccessReview", "", NotNamespaced | NoList}, + }, + "v1beta1": []Resource{ + {"LocalSubjectAccessReview", "", NoList}, + {"SelfSubjectAccessReview", "", NotNamespaced | NoList}, + {"SelfSubjectRulesReview", "", NotNamespaced | NoList}, + {"SubjectAccessReview", "", NotNamespaced | NoList}, + }, + }, + }, + { + Package: "autoscaling", + Group: "autoscaling", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"HorizontalPodAutoscaler", "", 0}, + }, + "v2beta1": []Resource{ + {"HorizontalPodAutoscaler", "", 0}, + }, + }, + }, + { + Package: "batch", + Group: "batch", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"Job", "", 0}, + }, + "v1beta1": []Resource{ + {"CronJob", "", 0}, + }, + "v2alpha1": []Resource{ + {"CronJob", "", 0}, + }, + }, + }, + { + Package: "certificates", + Group: "certificates.k8s.io", + Versions: map[string][]Resource{ + "v1beta1": []Resource{ + {"CertificateSigningRequest", "", NotNamespaced}, + }, + }, + }, + { + Package: "core", + Group: "", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"ComponentStatus", "componentstatuses", NotNamespaced}, + {"ConfigMap", "", 0}, + {"Endpoints", "endpoints", 0}, + {"LimitRange", "", 0}, + {"Namespace", "", NotNamespaced}, + {"Node", "", NotNamespaced}, + {"PersistentVolumeClaim", "", 0}, + {"PersistentVolume", "", NotNamespaced}, + {"Pod", "", 0}, + {"ReplicationController", "", 0}, + {"ResourceQuota", "", 0}, + {"Secret", "", 0}, + {"Service", "", 0}, + {"ServiceAccount", "", 0}, + }, + }, + }, + { + Package: "events", + Group: "events.k8s.io", + Versions: map[string][]Resource{ + "v1beta1": []Resource{ + {"Event", "", 0}, + }, + }, + }, + { + Package: "extensions", + Group: "extensions", + Versions: map[string][]Resource{ + "v1beta1": []Resource{ + {"DaemonSet", "", 0}, + {"Deployment", "", 0}, + {"Ingress", "ingresses", 0}, + {"NetworkPolicy", "networkpolicies", 0}, + {"PodSecurityPolicy", "podsecuritypolicies", NotNamespaced}, + {"ReplicaSet", "", 0}, + }, + }, + }, + { + Package: "networking", + Group: "networking.k8s.io", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"NetworkPolicy", "networkpolicies", 0}, + }, + }, + }, + { + Package: "policy", + Group: "policy", + Versions: map[string][]Resource{ + "v1beta1": []Resource{ + {"PodDisruptionBudget", "", 0}, + }, + }, + }, + { + Package: "rbac", + Group: "rbac.authorization.k8s.io", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"ClusterRole", "", NotNamespaced}, + {"ClusterRoleBinding", "", NotNamespaced}, + {"Role", "", 0}, + {"RoleBinding", "", 0}, + }, + "v1beta1": []Resource{ + {"ClusterRole", "", NotNamespaced}, + {"ClusterRoleBinding", "", NotNamespaced}, + {"Role", "", 0}, + {"RoleBinding", "", 0}, + }, + "v1alpha1": []Resource{ + {"ClusterRole", "", NotNamespaced}, + {"ClusterRoleBinding", "", NotNamespaced}, + {"Role", "", 0}, + {"RoleBinding", "", 0}, + }, + }, + }, + { + Package: "scheduling", + Group: "scheduling.k8s.io", + Versions: map[string][]Resource{ + "v1alpha1": []Resource{ + {"PriorityClass", "", NotNamespaced}, + }, + }, + }, + { + Package: "settings", + Group: "settings.k8s.io", + Versions: map[string][]Resource{ + "v1alpha1": []Resource{ + {"PodPreset", "", 0}, + }, + }, + }, + { + Package: "storage", + Group: "storage.k8s.io", + Versions: map[string][]Resource{ + "v1": []Resource{ + {"StorageClass", "", NotNamespaced}, + }, + "v1beta1": []Resource{ + {"StorageClass", "", NotNamespaced}, + }, + "v1alpha1": []Resource{ + {"VolumeAttachment", "", NotNamespaced}, + }, + }, + }, +} + +type templateData struct { + Package string + Resources []templateResource +} + +type templateResource struct { + Group string + Version string + Name string + Type string + Namespaced bool + List bool +} + +var tmpl = template.Must(template.New("").Parse(`package {{ .Package }} + +import "github.com/ericchiang/k8s" + +func init() { + {{- range $i, $r := .Resources -}} + k8s.Register("{{ $r.Group }}", "{{ $r.Version }}", "{{ $r.Name }}", {{ $r.Namespaced }}, &{{ $r.Type }}{}) + {{ end -}} + {{- range $i, $r := .Resources -}}{{ if $r.List }} + k8s.RegisterList("{{ $r.Group }}", "{{ $r.Version }}", "{{ $r.Name }}", {{ $r.Namespaced }}, &{{ $r.Type }}List{}){{ end -}} + {{ end -}} +} +`)) + +func main() { + for _, group := range apiGroups { + for version, resources := range group.Versions { + fp := filepath.Join("apis", group.Package, version, "register.go") + data := templateData{Package: version} + for _, r := range resources { + data.Resources = append(data.Resources, templateResource{ + Group: group.Group, + Version: version, + Name: r.Name, + Type: r.GoType, + Namespaced: r.Flags&NotNamespaced == 0, + List: r.Flags&NoList == 0, + }) + } + + buff := new(bytes.Buffer) + if err := tmpl.Execute(buff, &data); err != nil { + log.Fatal(err) + } + out, err := format.Source(buff.Bytes()) + if err != nil { + log.Fatal(err) + } + if err := ioutil.WriteFile(fp, out, 0644); err != nil { + log.Fatal(err) + } + } + } +} diff --git a/scripts/time.go.partial b/scripts/time.go.partial new file mode 100644 index 0000000..a4ceb6d --- /dev/null +++ b/scripts/time.go.partial @@ -0,0 +1,31 @@ +package v1 + +import ( + "encoding/json" + "time" +) + +// JSON marshaling logic for the Time type. Need to make +// third party resources JSON work. +func (t Time) MarshalJSON() ([]byte, error) { + var seconds, nanos int64 + if t.Seconds != nil { + seconds = *t.Seconds + } + if t.Nanos != nil { + nanos = int64(*t.Nanos) + } + return json.Marshal(time.Unix(seconds, nanos)) +} + +func (t *Time) UnmarshalJSON(p []byte) error { + var t1 time.Time + if err := json.Unmarshal(p, &t1); err != nil { + return err + } + seconds := t1.Unix() + nanos := int32(t1.UnixNano()) + t.Seconds = &seconds + t.Nanos = &nanos + return nil +} diff --git a/tprs.go b/tprs.go deleted file mode 100644 index b3f0776..0000000 --- a/tprs.go +++ /dev/null @@ -1,163 +0,0 @@ -package k8s - -import ( - "context" - "errors" - - "github.com/ericchiang/k8s/api/v1" - metav1 "github.com/ericchiang/k8s/apis/meta/v1" -) - -// ThirdPartyResources is a client used for interacting with user -// defined API groups. It uses JSON encoding instead of protobufs -// which are unsupported for these APIs. -// -// Users are expected to define their own third party resources. -// -// const metricsResource = "metrics" -// -// // First, define a third party resources with TypeMeta -// // and ObjectMeta fields. -// type Metric struct { -// *unversioned.TypeMeta `json:",inline"` -// *v1.ObjectMeta `json:"metadata,omitempty"` -// -// Timestamp time.Time `json:"timestamp"` -// Value []byte `json:"value"` -// } -// -// // Define a list wrapper. -// type MetricsList struct { -// *unversioned.TypeMeta `json:",inline"` -// *unversioned.ListMeta `json:"metadata,omitempty"` -// -// Items []Metric `json:"items"` -// } -// -// Register the new resource by creating a ThirdPartyResource type. -// -// // Create a ThirdPartyResource -// tpr := &v1beta1.ThirdPartyResource{ -// Metadata: &v1.ObjectMeta{ -// Name: k8s.String("metric.metrics.example.com"), -// }, -// Description: k8s.String("A custom third party resource"), -// Versions: []*v1beta1.APIVersion{ -// {Name: k8s.String("v1")}, -// }, -// } -// _, err := client.ExtensionsV1Beta1().CreateThirdPartyResource(ctx, trp) -// if err != nil { -// // handle error -// } -// -// After creating the resource type, create a ThirdPartyResources client then -// use interact with it like any other API group. For example to create a third -// party resource: -// -// metricsClient := client.ThirdPartyResources("metrics.example.com", "v1") -// -// metric := &Metric{ -// ObjectMeta: &v1.ObjectMeta{ -// Name: k8s.String("foo"), -// }, -// Timestamp: time.Now(), -// Value: 42, -// } -// -// err = metricsClient.Create(ctx, metricsResource, client.Namespace, metric, metric) -// if err != nil { -// // handle error -// } -// -// List a set of third party resources: -// -// var metrics MetricsList -// metricsClient.List(ctx, metricsResource, &metrics) -// -// Or delete: -// -// tprClient.Delete(ctx, metricsResource, client.Namespace, *metric.Name) -// -type ThirdPartyResources struct { - c *Client - - apiGroup string - apiVersion string -} - -// ThirdPartyResources returns a client for interacting with a ThirdPartyResource -// API group. -func (c *Client) ThirdPartyResources(apiGroup, apiVersion string) *ThirdPartyResources { - return &ThirdPartyResources{c, apiGroup, apiVersion} -} - -func checkResource(apiGroup, apiVersion, resource, namespace, name string) error { - if apiGroup == "" { - return errors.New("no api group provided") - } - if apiVersion == "" { - return errors.New("no api version provided") - } - if resource == "" { - return errors.New("no resource version provided") - } - if name == "" { - return errors.New("no resource name provided") - } - return nil -} - -// object and after16Object are used by go/types to detect types that are likely -// to be Kubernetes resources. Types that implement this resources are likely -// resource. -// -// They're defined here but only used in gen.go. -type object interface { - GetMetadata() *v1.ObjectMeta -} - -// after16Object uses the new ObjectMeta's home. -type after16Object interface { - GetMetadata() *metav1.ObjectMeta -} - -func (t *ThirdPartyResources) Create(ctx context.Context, resource, namespace string, req, resp interface{}) error { - if err := checkResource(t.apiGroup, t.apiVersion, resource, namespace, "not required"); err != nil { - return err - } - url := t.c.urlFor(t.apiGroup, t.apiVersion, namespace, resource, "") - return t.c.create(ctx, jsonCodec, "POST", url, req, resp) -} - -func (t *ThirdPartyResources) Update(ctx context.Context, resource, namespace, name string, req, resp interface{}) error { - if err := checkResource(t.apiGroup, t.apiVersion, resource, namespace, "not required"); err != nil { - return err - } - url := t.c.urlFor(t.apiGroup, t.apiVersion, namespace, resource, name) - return t.c.create(ctx, jsonCodec, "PUT", url, req, resp) -} - -func (t *ThirdPartyResources) Get(ctx context.Context, resource, namespace, name string, resp interface{}) error { - if err := checkResource(t.apiGroup, t.apiVersion, resource, namespace, name); err != nil { - return err - } - url := t.c.urlFor(t.apiGroup, t.apiVersion, namespace, resource, name) - return t.c.get(ctx, jsonCodec, url, resp) -} - -func (t *ThirdPartyResources) Delete(ctx context.Context, resource, namespace, name string) error { - if err := checkResource(t.apiGroup, t.apiVersion, resource, namespace, name); err != nil { - return err - } - url := t.c.urlFor(t.apiGroup, t.apiVersion, namespace, resource, name) - return t.c.delete(ctx, jsonCodec, url) -} - -func (t *ThirdPartyResources) List(ctx context.Context, resource, namespace string, resp interface{}) error { - if err := checkResource(t.apiGroup, t.apiVersion, resource, namespace, "name not required"); err != nil { - return err - } - url := t.c.urlFor(t.apiGroup, t.apiVersion, namespace, resource, "") - return t.c.get(ctx, jsonCodec, url, resp) -} diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..91c82dc --- /dev/null +++ b/watch.go @@ -0,0 +1,199 @@ +package k8s + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + + "github.com/ericchiang/k8s/runtime" + "github.com/ericchiang/k8s/watch/versioned" + "github.com/golang/protobuf/proto" +) + +// Decode events from a watch stream. +// +// See: https://github.com/kubernetes/community/blob/master/contributors/design-proposals/protobuf.md#streaming-wire-format + +// Watcher receives a stream of events tracking a particular resource within +// a namespace or across all namespaces. +// +// Watcher does not automatically reconnect. If a watch fails, a new watch must +// be initialized. +type Watcher struct { + watcher interface { + Next(Resource) (string, error) + Close() error + } +} + +// Next decodes the next event from the watch stream. Errors are fatal, and +// indicate that the watcher should no longer be used, and must be recreated. +func (w *Watcher) Next(r Resource) (string, error) { + return w.watcher.Next(r) +} + +// Close closes the active connection with the API server being used for +// the watch. +func (w *Watcher) Close() error { + return w.watcher.Close() +} + +type watcherJSON struct { + d *json.Decoder + c io.Closer +} + +func (w *watcherJSON) Close() error { + return w.c.Close() +} + +func (w *watcherJSON) Next(r Resource) (string, error) { + var event struct { + Type string `json:"type"` + Object json.RawMessage `json:"object"` + } + if err := w.d.Decode(&event); err != nil { + return "", fmt.Errorf("decode event: %v", err) + } + if event.Type == "" { + return "", errors.New("wwatch event had no type field") + } + if err := json.Unmarshal([]byte(event.Object), r); err != nil { + return "", fmt.Errorf("decode resource: %v", err) + } + return event.Type, nil +} + +type watcherPB struct { + r io.ReadCloser +} + +func (w *watcherPB) Next(r Resource) (string, error) { + msg, ok := r.(proto.Message) + if !ok { + return "", errors.New("object was not a protobuf message") + } + event, unknown, err := w.next() + if err != nil { + return "", err + } + if event.Type == nil || *event.Type == "" { + return "", errors.New("watch event had no type field") + } + if err := proto.Unmarshal(unknown.Raw, msg); err != nil { + return "", err + } + return *event.Type, nil +} + +func (w *watcherPB) Close() error { + return w.r.Close() +} + +func (w *watcherPB) next() (*versioned.Event, *runtime.Unknown, error) { + length := make([]byte, 4) + if _, err := io.ReadFull(w.r, length); err != nil { + return nil, nil, err + } + + body := make([]byte, int(binary.BigEndian.Uint32(length))) + if _, err := io.ReadFull(w.r, body); err != nil { + return nil, nil, fmt.Errorf("read frame body: %v", err) + } + + var event versioned.Event + if err := proto.Unmarshal(body, &event); err != nil { + return nil, nil, err + } + + if event.Object == nil { + return nil, nil, fmt.Errorf("event had no underlying object") + } + + unknown, err := parseUnknown(event.Object.Raw) + if err != nil { + return nil, nil, err + } + + return &event, unknown, nil +} + +var unknownPrefix = []byte{0x6b, 0x38, 0x73, 0x00} + +func parseUnknown(b []byte) (*runtime.Unknown, error) { + if !bytes.HasPrefix(b, unknownPrefix) { + return nil, errors.New("bytes did not start with expected prefix") + } + + var u runtime.Unknown + if err := proto.Unmarshal(b[len(unknownPrefix):], &u); err != nil { + return nil, err + } + return &u, nil +} + +// Watch creates a watch on a resource. It takes an example Resource to +// determine what endpoint to watch. +// +// Watch does not automatically reconnect. If a watch fails, a new watch must +// be initialized. +// +// // Watch configmaps in the "kube-system" namespace +// var configMap corev1.ConfigMap +// watcher, err := client.Watch(ctx, "kube-system", &configMap) +// if err != nil { +// // handle error +// } +// defer watcher.Close() // Always close the returned watcher. +// +// for { +// cm := new(corev1.ConfigMap) +// eventType, err := watcher.Next(cm) +// if err != nil { +// // watcher encountered and error, exit or create a new watcher +// } +// fmt.Println(eventType, *cm.Metadata.Name) +// } +// +func (c *Client) Watch(ctx context.Context, namespace string, r Resource, options ...Option) (*Watcher, error) { + url, err := resourceWatchURL(c.Endpoint, namespace, r, options...) + if err != nil { + return nil, err + } + + ct := contentTypeFor(r) + + req, err := c.newRequest(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", ct) + + resp, err := c.client().Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode/100 != 2 { + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + return nil, newAPIError(resp.Header.Get("Content-Type"), resp.StatusCode, body) + } + + if ct == contentTypePB { + return &Watcher{&watcherPB{r: resp.Body}}, nil + } + + return &Watcher{&watcherJSON{ + d: json.NewDecoder(resp.Body), + c: resp.Body, + }}, nil +} diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 0000000..70bf429 --- /dev/null +++ b/watch_test.go @@ -0,0 +1,105 @@ +package k8s_test + +import ( + "context" + "reflect" + "testing" + + "github.com/ericchiang/k8s" + corev1 "github.com/ericchiang/k8s/apis/core/v1" + metav1 "github.com/ericchiang/k8s/apis/meta/v1" +) + +// configMapJSON is used to test the JSON serialization watch. +type configMapJSON struct { + Metadata *metav1.ObjectMeta `json:"metadata"` + Data map[string]string `json:"data"` +} + +func (c *configMapJSON) GetMetadata() *metav1.ObjectMeta { + return c.Metadata +} + +func init() { + k8s.Register("", "v1", "configmaps", true, &configMapJSON{}) +} + +func testWatch(t *testing.T, client *k8s.Client, namespace string, newCM func() k8s.Resource, update func(cm k8s.Resource)) { + w, err := client.Watch(context.TODO(), namespace, newCM()) + if err != nil { + t.Errorf("watch configmaps: %v", err) + } + defer w.Close() + + cm := newCM() + want := func(eventType string) { + got := newCM() + eT, err := w.Next(got) + if err != nil { + t.Errorf("decode watch event: %v", err) + return + } + if eT != eventType { + t.Errorf("expected event type %q got %q", eventType, eT) + } + if !reflect.DeepEqual(got, cm) { + t.Errorf("configmaps did not match\nwant=%#v\ngot=%#v", cm, got) + } + } + + if err := client.Create(context.TODO(), cm); err != nil { + t.Errorf("create configmap: %v", err) + return + } + want(k8s.EventAdded) + + update(cm) + + if err := client.Update(context.TODO(), cm); err != nil { + t.Errorf("update configmap: %v", err) + return + } + want(k8s.EventModified) + + if err := client.Delete(context.TODO(), cm); err != nil { + t.Errorf("Delete configmap: %v", err) + return + } + want(k8s.EventDeleted) +} + +func TestWatchConfigMapJSON(t *testing.T) { + withNamespace(t, func(client *k8s.Client, namespace string) { + newCM := func() k8s.Resource { + return &configMapJSON{ + Metadata: &metav1.ObjectMeta{ + Name: k8s.String("my-configmap"), + Namespace: &namespace, + }, + } + } + + updateCM := func(cm k8s.Resource) { + (cm.(*configMapJSON)).Data = map[string]string{"hello": "world"} + } + testWatch(t, client, namespace, newCM, updateCM) + }) +} + +func TestWatchConfigMapProto(t *testing.T) { + withNamespace(t, func(client *k8s.Client, namespace string) { + newCM := func() k8s.Resource { + return &corev1.ConfigMap{ + Metadata: &metav1.ObjectMeta{ + Name: k8s.String("my-configmap"), + Namespace: &namespace, + }, + } + } + + updateCM := func(cm k8s.Resource) { + (cm.(*corev1.ConfigMap)).Data = map[string]string{"hello": "world"} + } + testWatch(t, client, namespace, newCM, updateCM) + }) +}