diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index a5986bbe919..8448517d61e 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -17,30 +17,19 @@ limitations under the License. package apiserver import ( - "bytes" "encoding/json" "fmt" "io/ioutil" - "net" "net/http" - "net/http/httputil" - "net/url" - "path" "runtime/debug" "strings" "time" - "code.google.com/p/go.net/html" - "code.google.com/p/go.net/html/atom" - "code.google.com/p/go.net/websocket" "github.com/GoogleCloudPlatform/kubernetes/pkg/api" "github.com/GoogleCloudPlatform/kubernetes/pkg/healthz" "github.com/GoogleCloudPlatform/kubernetes/pkg/httplog" "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" - "github.com/GoogleCloudPlatform/kubernetes/pkg/tools" - "github.com/GoogleCloudPlatform/kubernetes/pkg/util" "github.com/GoogleCloudPlatform/kubernetes/pkg/version" - "github.com/GoogleCloudPlatform/kubernetes/pkg/watch" "github.com/golang/glog" ) @@ -63,66 +52,6 @@ func NewNotFoundErr(kind, name string) error { return errNotFound(fmt.Sprintf("%s %q not found", kind, name)) } -// RESTStorage is a generic interface for RESTful storage services -// Resources which are exported to the RESTful API of apiserver need to implement this interface. -type RESTStorage interface { - // List selects resources in the storage which match to the selector. - List(labels.Selector) (interface{}, error) - - // Get finds a resource in the storage by id and returns it. - // Although it can return an arbitrary error value, IsNotFound(err) is true for the returned error value err when the specified resource is not found. - Get(id string) (interface{}, error) - - // Delete finds a resource in the storage and deletes it. - // Although it can return an arbitrary error value, IsNotFound(err) is true for the returned error value err when the specified resource is not found. - Delete(id string) (<-chan interface{}, error) - - Extract(body []byte) (interface{}, error) - Create(interface{}) (<-chan interface{}, error) - Update(interface{}) (<-chan interface{}, error) -} - -// ResourceWatcher should be implemented by all RESTStorage objects that -// want to offer the ability to watch for changes through the watch api. -type ResourceWatcher interface { - WatchAll() (watch.Interface, error) - WatchSingle(id string) (watch.Interface, error) -} - -// WorkFunc is used to perform any time consuming work for an api call, after -// the input has been validated. Pass one of these to MakeAsync to create an -// appropriate return value for the Update, Delete, and Create methods. -type WorkFunc func() (result interface{}, err error) - -// MakeAsync takes a function and executes it, delivering the result in the way required -// by RESTStorage's Update, Delete, and Create methods. -func MakeAsync(fn WorkFunc) <-chan interface{} { - channel := make(chan interface{}) - go func() { - defer util.HandleCrash() - obj, err := fn() - if err != nil { - status := http.StatusInternalServerError - switch { - case tools.IsEtcdConflict(err): - status = http.StatusConflict - } - channel <- &api.Status{ - Status: api.StatusFailure, - Details: err.Error(), - Code: status, - } - } else { - channel <- obj - } - // 'close' is used to signal that no further values will - // be written to the channel. Not strictly necessary, but - // also won't hurt. - close(channel) - }() - return channel -} - // APIServer is an HTTPHandler that delegates to RESTStorage objects. // It handles URLs of the form: // ${prefix}/${storage_key}[/${object_name}] @@ -147,177 +76,32 @@ func New(storage map[string]RESTStorage, prefix string) *APIServer { mux: http.NewServeMux(), } - s.mux.Handle("/logs/", http.StripPrefix("/logs/", http.FileServer(http.Dir("/var/log/")))) - s.mux.HandleFunc(s.prefix+"/", s.ServeREST) - healthz.InstallHandler(s.mux) - - s.mux.HandleFunc("/", s.handleIndex) - s.mux.HandleFunc("/version", s.handleVersionReq) - - // Handle both operations and operations/* with the same handler - s.mux.HandleFunc(s.operationPrefix(), s.handleOperationRequest) - s.mux.HandleFunc(s.operationPrefix()+"/", s.handleOperationRequest) - + // Primary API methods + s.mux.HandleFunc(s.prefix+"/", s.handleREST) s.mux.HandleFunc(s.watchPrefix()+"/", s.handleWatch) - s.mux.HandleFunc("/proxy/minion/", s.handleMinionReq) + // Support services for the apiserver + s.mux.Handle("/logs/", http.StripPrefix("/logs/", http.FileServer(http.Dir("/var/log/")))) + healthz.InstallHandler(s.mux) + s.mux.HandleFunc("/version", handleVersion) + s.mux.HandleFunc("/", handleIndex) + + // Handle both operations and operations/* with the same handler + s.mux.HandleFunc(s.operationPrefix(), s.handleOperation) + s.mux.HandleFunc(s.operationPrefix()+"/", s.handleOperation) + + // Proxy minion requests + s.mux.HandleFunc("/proxy/minion/", s.handleProxyMinion) return s } -func (server *APIServer) operationPrefix() string { - return path.Join(server.prefix, "operations") -} - -func (server *APIServer) watchPrefix() string { - return path.Join(server.prefix, "watch") -} - -func (server *APIServer) handleIndex(w http.ResponseWriter, req *http.Request) { - if req.URL.Path != "/" && req.URL.Path != "/index.html" { - server.notFound(w, req) - return - } - w.WriteHeader(http.StatusOK) - // TODO: serve this out of a file? - data := "
Welcome to Kubernetes" - fmt.Fprint(w, data) -} - -// handleVersionReq writes the server's version information. -func (server *APIServer) handleVersionReq(w http.ResponseWriter, req *http.Request) { - server.writeRawJSON(http.StatusOK, version.Get(), w) -} - -func (server *APIServer) handleMinionReq(w http.ResponseWriter, req *http.Request) { - minionPrefix := "/proxy/minion/" - if !strings.HasPrefix(req.URL.Path, minionPrefix) { - server.notFound(w, req) - return - } - - path := req.URL.Path[len(minionPrefix):] - rawQuery := req.URL.RawQuery - - // Expect path as: ${minion}/${query_to_minion} - // and query_to_minion can be any query that kubelet will accept. - // - // For example: - // To query stats of a minion or a pod or a container, - // path string can be ${minion}/stats/kubelet.loggoogle.log`) - transport := &minionTransport{} - - // Test /logs/ - request := &http.Request{ - Method: "GET", - URL: &url.URL{ - Scheme: "http", - Host: "minion1:10250", - Path: "/logs/", - }, - } - response := &http.Response{ - Status: "200 OK", - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(content)), - Close: true, - } - updated_resp, _ := transport.ProcessResponse(request, response) - body, _ := ioutil.ReadAll(updated_resp.Body) - expected := string(`
kubelet.loggoogle.log`) - if !strings.Contains(string(body), expected) { - t.Errorf("Received wrong content: %s", string(body)) - } - - // Test subdir under /logs/ - request = &http.Request{ - Method: "GET", - URL: &url.URL{ - Scheme: "http", - Host: "minion1:8080", - Path: "/whatever/apt/", - }, - } - response = &http.Response{ - Status: "200 OK", - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(content)), - Close: true, - } - updated_resp, _ = transport.ProcessResponse(request, response) - body, _ = ioutil.ReadAll(updated_resp.Body) - expected = string(`
kubelet.loggoogle.log`) - if !strings.Contains(string(body), expected) { - t.Errorf("Received wrong content: %s", string(body)) - } -} diff --git a/pkg/apiserver/async.go b/pkg/apiserver/async.go new file mode 100644 index 00000000000..70b190354b0 --- /dev/null +++ b/pkg/apiserver/async.go @@ -0,0 +1,59 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "net/http" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/tools" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" +) + +// WorkFunc is used to perform any time consuming work for an api call, after +// the input has been validated. Pass one of these to MakeAsync to create an +// appropriate return value for the Update, Delete, and Create methods. +type WorkFunc func() (result interface{}, err error) + +// MakeAsync takes a function and executes it, delivering the result in the way required +// by RESTStorage's Update, Delete, and Create methods. +func MakeAsync(fn WorkFunc) <-chan interface{} { + channel := make(chan interface{}) + go func() { + defer util.HandleCrash() + obj, err := fn() + if err != nil { + status := http.StatusInternalServerError + switch { + case tools.IsEtcdConflict(err): + status = http.StatusConflict + } + channel <- &api.Status{ + Status: api.StatusFailure, + Details: err.Error(), + Code: status, + } + } else { + channel <- obj + } + // 'close' is used to signal that no further values will + // be written to the channel. Not strictly necessary, but + // also won't hurt. + close(channel) + }() + return channel +} diff --git a/pkg/apiserver/errors.go b/pkg/apiserver/errors.go new file mode 100644 index 00000000000..34bf293038a --- /dev/null +++ b/pkg/apiserver/errors.go @@ -0,0 +1,34 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "fmt" + "net/http" +) + +// internalError renders a generic error to the response +func internalError(err error, w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Internal Error: %#v", err) +} + +// notFound renders a simple not found error +func notFound(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Not Found: %#v", req.RequestURI) +} diff --git a/pkg/apiserver/index.go b/pkg/apiserver/index.go new file mode 100644 index 00000000000..7c682209caa --- /dev/null +++ b/pkg/apiserver/index.go @@ -0,0 +1,34 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "fmt" + "net/http" +) + +// handleIndex is the root index page for Kubernetes +func handleIndex(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/" && req.URL.Path != "/index.html" { + notFound(w, req) + return + } + w.WriteHeader(http.StatusOK) + // TODO: serve this out of a file? + data := "Welcome to Kubernetes" + fmt.Fprint(w, data) +} diff --git a/pkg/apiserver/interfaces.go b/pkg/apiserver/interfaces.go new file mode 100644 index 00000000000..c268d532a69 --- /dev/null +++ b/pkg/apiserver/interfaces.go @@ -0,0 +1,48 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/watch" +) + +// RESTStorage is a generic interface for RESTful storage services +// Resources which are exported to the RESTful API of apiserver need to implement this interface. +type RESTStorage interface { + // List selects resources in the storage which match to the selector. + List(labels.Selector) (interface{}, error) + + // Get finds a resource in the storage by id and returns it. + // Although it can return an arbitrary error value, IsNotFound(err) is true for the returned error value err when the specified resource is not found. + Get(id string) (interface{}, error) + + // Delete finds a resource in the storage and deletes it. + // Although it can return an arbitrary error value, IsNotFound(err) is true for the returned error value err when the specified resource is not found. + Delete(id string) (<-chan interface{}, error) + + Extract(body []byte) (interface{}, error) + Create(interface{}) (<-chan interface{}, error) + Update(interface{}) (<-chan interface{}, error) +} + +// ResourceWatcher should be implemented by all RESTStorage objects that +// want to offer the ability to watch for changes through the watch api. +type ResourceWatcher interface { + WatchAll() (watch.Interface, error) + WatchSingle(id string) (watch.Interface, error) +} diff --git a/pkg/apiserver/minionproxy.go b/pkg/apiserver/minionproxy.go new file mode 100644 index 00000000000..87e3fc4bbab --- /dev/null +++ b/pkg/apiserver/minionproxy.go @@ -0,0 +1,155 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "bytes" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "code.google.com/p/go.net/html" + "code.google.com/p/go.net/html/atom" + "github.com/golang/glog" +) + +func (server *APIServer) handleProxyMinion(w http.ResponseWriter, req *http.Request) { + minionPrefix := "/proxy/minion/" + if !strings.HasPrefix(req.URL.Path, minionPrefix) { + notFound(w, req) + return + } + + path := req.URL.Path[len(minionPrefix):] + rawQuery := req.URL.RawQuery + + // Expect path as: ${minion}/${query_to_minion} + // and query_to_minion can be any query that kubelet will accept. + // + // For example: + // To query stats of a minion or a pod or a container, + // path string can be ${minion}/stats/
kubelet.loggoogle.log`) + transport := &minionTransport{} + + // Test /logs/ + request := &http.Request{ + Method: "GET", + URL: &url.URL{ + Scheme: "http", + Host: "minion1:10250", + Path: "/logs/", + }, + } + response := &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(content)), + Close: true, + } + updated_resp, _ := transport.ProcessResponse(request, response) + body, _ := ioutil.ReadAll(updated_resp.Body) + expected := string(`
kubelet.loggoogle.log`) + if !strings.Contains(string(body), expected) { + t.Errorf("Received wrong content: %s", string(body)) + } + + // Test subdir under /logs/ + request = &http.Request{ + Method: "GET", + URL: &url.URL{ + Scheme: "http", + Host: "minion1:8080", + Path: "/whatever/apt/", + }, + } + response = &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(content)), + Close: true, + } + updated_resp, _ = transport.ProcessResponse(request, response) + body, _ = ioutil.ReadAll(updated_resp.Body) + expected = string(`
kubelet.loggoogle.log`) + if !strings.Contains(string(body), expected) { + t.Errorf("Received wrong content: %s", string(body)) + } +} diff --git a/pkg/apiserver/operation.go b/pkg/apiserver/operation.go index 7b88531d214..90474adeb7f 100644 --- a/pkg/apiserver/operation.go +++ b/pkg/apiserver/operation.go @@ -17,8 +17,11 @@ limitations under the License. package apiserver import ( + "net/http" + "path" "sort" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -27,6 +30,47 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/pkg/util" ) +func (s *APIServer) operationPrefix() string { + return path.Join(s.prefix, "operations") +} + +func (s *APIServer) handleOperation(w http.ResponseWriter, req *http.Request) { + opPrefix := s.operationPrefix() + if !strings.HasPrefix(req.URL.Path, opPrefix) { + notFound(w, req) + return + } + trimmed := strings.TrimLeft(req.URL.Path[len(opPrefix):], "/") + parts := strings.Split(trimmed, "/") + if len(parts) > 1 { + notFound(w, req) + return + } + if req.Method != "GET" { + notFound(w, req) + return + } + if len(parts) == 0 { + // List outstanding operations. + list := s.ops.List() + writeJSON(http.StatusOK, list, w) + return + } + + op := s.ops.Get(parts[0]) + if op == nil { + notFound(w, req) + return + } + + obj, complete := op.StatusOrResult() + if complete { + writeJSON(http.StatusOK, obj, w) + } else { + writeJSON(http.StatusAccepted, obj, w) + } +} + // Operation represents an ongoing action which the server is performing. type Operation struct { ID string diff --git a/pkg/apiserver/operation_test.go b/pkg/apiserver/operation_test.go index 35b06ab1547..a828dfab796 100644 --- a/pkg/apiserver/operation_test.go +++ b/pkg/apiserver/operation_test.go @@ -17,9 +17,14 @@ limitations under the License. package apiserver import ( + "bytes" + "net/http" + "net/http/httptest" "sync/atomic" "testing" "time" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" ) func TestOperation(t *testing.T) { @@ -84,3 +89,41 @@ func TestOperation(t *testing.T) { t.Errorf("Got unexpected result: %#v", op.result) } } + +func TestOpGet(t *testing.T) { + simpleStorage := &SimpleRESTStorage{} + handler := New(map[string]RESTStorage{ + "foo": simpleStorage, + }, "/prefix/version") + server := httptest.NewServer(handler) + client := http.Client{} + + simple := Simple{ + Name: "foo", + } + data, err := api.Encode(simple) + t.Log(string(data)) + expectNoError(t, err) + request, err := http.NewRequest("POST", server.URL+"/prefix/version/foo", bytes.NewBuffer(data)) + expectNoError(t, err) + response, err := client.Do(request) + expectNoError(t, err) + if response.StatusCode != http.StatusAccepted { + t.Errorf("Unexpected response %#v", response) + } + + var itemOut api.Status + body, err := extractBody(response, &itemOut) + expectNoError(t, err) + if itemOut.Status != api.StatusWorking || itemOut.Details == "" { + t.Errorf("Unexpected status: %#v (%s)", itemOut, string(body)) + } + + req2, err := http.NewRequest("GET", server.URL+"/prefix/version/operations/"+itemOut.Details, nil) + expectNoError(t, err) + _, err = client.Do(req2) + expectNoError(t, err) + if response.StatusCode != http.StatusAccepted { + t.Errorf("Unexpected response %#v", response) + } +} diff --git a/pkg/apiserver/watch.go b/pkg/apiserver/watch.go new file mode 100644 index 00000000000..b2f1cc1853b --- /dev/null +++ b/pkg/apiserver/watch.go @@ -0,0 +1,161 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "encoding/json" + "net/http" + "path" + "strings" + + "code.google.com/p/go.net/websocket" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/httplog" + "github.com/GoogleCloudPlatform/kubernetes/pkg/watch" +) + +func (s *APIServer) watchPrefix() string { + return path.Join(s.prefix, "watch") +} + +// handleWatch processes a watch request +func (s *APIServer) handleWatch(w http.ResponseWriter, req *http.Request) { + prefix := s.watchPrefix() + if !strings.HasPrefix(req.URL.Path, prefix) { + notFound(w, req) + return + } + parts := strings.Split(req.URL.Path[len(prefix):], "/")[1:] + if req.Method != "GET" || len(parts) < 1 { + notFound(w, req) + } + storage := s.storage[parts[0]] + if storage == nil { + notFound(w, req) + } + if watcher, ok := storage.(ResourceWatcher); ok { + var watching watch.Interface + var err error + if id := req.URL.Query().Get("id"); id != "" { + watching, err = watcher.WatchSingle(id) + } else { + watching, err = watcher.WatchAll() + } + if err != nil { + internalError(err, w) + return + } + + // TODO: This is one watch per connection. We want to multiplex, so that + // multiple watches of the same thing don't create two watches downstream. + watchServer := &WatchServer{watching} + if req.Header.Get("Connection") == "Upgrade" && req.Header.Get("Upgrade") == "websocket" { + websocket.Handler(watchServer.HandleWS).ServeHTTP(httplog.Unlogged(w), req) + } else { + watchServer.ServeHTTP(w, req) + } + return + } + + notFound(w, req) +} + +// WatchServer serves a watch.Interface over a websocket or vanilla HTTP. +type WatchServer struct { + watching watch.Interface +} + +// HandleWS implements a websocket handler. +func (w *WatchServer) HandleWS(ws *websocket.Conn) { + done := make(chan struct{}) + go func() { + var unused interface{} + // Expect this to block until the connection is closed. Client should not + // send anything. + websocket.JSON.Receive(ws, &unused) + close(done) + }() + for { + select { + case <-done: + w.watching.Stop() + return + case event, ok := <-w.watching.ResultChan(): + if !ok { + // End of results. + return + } + err := websocket.JSON.Send(ws, &api.WatchEvent{ + Type: event.Type, + Object: api.APIObject{event.Object}, + }) + if err != nil { + // Client disconnect. + w.watching.Stop() + return + } + } + } +} + +// ServeHTTP serves a series of JSON encoded events via straight HTTP with +// Transfer-Encoding: chunked. +func (self *WatchServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { + loggedW := httplog.LogOf(w) + w = httplog.Unlogged(w) + + cn, ok := w.(http.CloseNotifier) + if !ok { + loggedW.Addf("unable to get CloseNotifier") + http.NotFound(loggedW, req) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + loggedW.Addf("unable to get Flusher") + http.NotFound(loggedW, req) + return + } + + loggedW.Header().Set("Transfer-Encoding", "chunked") + loggedW.WriteHeader(http.StatusOK) + flusher.Flush() + + encoder := json.NewEncoder(w) + for { + select { + case <-cn.CloseNotify(): + self.watching.Stop() + return + case event, ok := <-self.watching.ResultChan(): + if !ok { + // End of results. + return + } + err := encoder.Encode(&api.WatchEvent{ + Type: event.Type, + Object: api.APIObject{event.Object}, + }) + if err != nil { + // Client disconnect. + self.watching.Stop() + return + } + flusher.Flush() + } + } +} diff --git a/pkg/apiserver/watch_test.go b/pkg/apiserver/watch_test.go new file mode 100644 index 00000000000..09fdcfa6241 --- /dev/null +++ b/pkg/apiserver/watch_test.go @@ -0,0 +1,142 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 apiserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "code.google.com/p/go.net/websocket" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/watch" +) + +var watchTestTable = []struct { + t watch.EventType + obj interface{} +}{ + {watch.Added, &Simple{Name: "A Name"}}, + {watch.Modified, &Simple{Name: "Another Name"}}, + {watch.Deleted, &Simple{Name: "Another Name"}}, +} + +func TestWatchWebsocket(t *testing.T) { + simpleStorage := &SimpleRESTStorage{} + handler := New(map[string]RESTStorage{ + "foo": simpleStorage, + }, "/prefix/version") + server := httptest.NewServer(handler) + + dest, _ := url.Parse(server.URL) + dest.Scheme = "ws" // Required by websocket, though the server never sees it. + dest.Path = "/prefix/version/watch/foo" + dest.RawQuery = "id=myID" + + ws, err := websocket.Dial(dest.String(), "", "http://localhost") + expectNoError(t, err) + + if a, e := simpleStorage.requestedID, "myID"; a != e { + t.Fatalf("Expected %v, got %v", e, a) + } + + try := func(action watch.EventType, object interface{}) { + // Send + simpleStorage.fakeWatch.Action(action, object) + // Test receive + var got api.WatchEvent + err := websocket.JSON.Receive(ws, &got) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if got.Type != action { + t.Errorf("Unexpected type: %v", got.Type) + } + if e, a := object, got.Object.Object; !reflect.DeepEqual(e, a) { + t.Errorf("Expected %v, got %v", e, a) + } + } + + for _, item := range watchTestTable { + try(item.t, item.obj) + } + simpleStorage.fakeWatch.Stop() + + var got api.WatchEvent + err = websocket.JSON.Receive(ws, &got) + if err == nil { + t.Errorf("Unexpected non-error") + } +} + +func TestWatchHTTP(t *testing.T) { + simpleStorage := &SimpleRESTStorage{} + handler := New(map[string]RESTStorage{ + "foo": simpleStorage, + }, "/prefix/version") + server := httptest.NewServer(handler) + client := http.Client{} + + dest, _ := url.Parse(server.URL) + dest.Path = "/prefix/version/watch/foo" + dest.RawQuery = "id=myID" + + request, err := http.NewRequest("GET", dest.String(), nil) + expectNoError(t, err) + response, err := client.Do(request) + expectNoError(t, err) + if response.StatusCode != http.StatusOK { + t.Errorf("Unexpected response %#v", response) + } + + if a, e := simpleStorage.requestedID, "myID"; a != e { + t.Fatalf("Expected %v, got %v", e, a) + } + + decoder := json.NewDecoder(response.Body) + + try := func(action watch.EventType, object interface{}) { + // Send + simpleStorage.fakeWatch.Action(action, object) + // Test receive + var got api.WatchEvent + err := decoder.Decode(&got) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if got.Type != action { + t.Errorf("Unexpected type: %v", got.Type) + } + if e, a := object, got.Object.Object; !reflect.DeepEqual(e, a) { + t.Errorf("Expected %v, got %v", e, a) + } + } + + for _, item := range watchTestTable { + try(item.t, item.obj) + } + simpleStorage.fakeWatch.Stop() + + var got api.WatchEvent + err = decoder.Decode(&got) + if err == nil { + t.Errorf("Unexpected non-error") + } +}