diff --git a/pkg/api/errors/errors.go b/pkg/api/errors/errors.go index 760de6dd334..8551f49cd46 100644 --- a/pkg/api/errors/errors.go +++ b/pkg/api/errors/errors.go @@ -105,7 +105,7 @@ func NewInvalid(kind, name string, errs ValidationErrorList) error { } return &statusError{api.Status{ Status: api.StatusFailure, - Code: 422, // RFC 4918 + Code: 422, // RFC 4918: StatusUnprocessableEntity Reason: api.StatusReasonInvalid, Details: &api.StatusDetails{ Kind: kind, @@ -121,7 +121,7 @@ func NewBadRequest(reason string) error { return &statusError{ api.Status{ Status: api.StatusFailure, - Code: 400, + Code: http.StatusBadRequest, Reason: api.StatusReasonBadRequest, Details: &api.StatusDetails{ Causes: []api.StatusCause{ @@ -136,7 +136,7 @@ func NewBadRequest(reason string) error { func NewInternalError(err error) error { return &statusError{api.Status{ Status: api.StatusFailure, - Code: 500, + Code: http.StatusInternalServerError, Reason: api.StatusReasonInternalError, Details: &api.StatusDetails{ Causes: []api.StatusCause{{Message: err.Error()}}, diff --git a/pkg/api/helpers.go b/pkg/api/helpers.go index a4e9aab3a7f..a593dde3d90 100644 --- a/pkg/api/helpers.go +++ b/pkg/api/helpers.go @@ -20,6 +20,8 @@ import ( "strings" ) +// TODO: Address these per #1502 + func IsPullAlways(p PullPolicy) bool { return pullPoliciesEqual(p, PullAlways) } diff --git a/pkg/api/unversioned.go b/pkg/api/unversioned.go new file mode 100644 index 00000000000..4f405580b13 --- /dev/null +++ b/pkg/api/unversioned.go @@ -0,0 +1,26 @@ +/* +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 api + +// This file contains API types that are unversioned. + +// APIVersions lists the api versions that are available, to allow +// version negotiation. APIVersions isn't just an unnamed array of +// strings in order to allow for future evolution, though unversioned +type APIVersions struct { + Versions []string `json:"versions" yaml:"versions"` +} diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index 6166202befc..49249e24373 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -20,13 +20,17 @@ import ( "encoding/json" "io/ioutil" "net/http" + "path" + "reflect" "strings" "time" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" "github.com/GoogleCloudPlatform/kubernetes/pkg/healthz" "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" "github.com/GoogleCloudPlatform/kubernetes/pkg/version" + "github.com/emicklei/go-restful" "github.com/golang/glog" ) @@ -39,7 +43,7 @@ type Mux interface { // defaultAPIServer exposes nested objects for testability. type defaultAPIServer struct { http.Handler - group *APIGroup + group *APIGroupVersion } const ( @@ -49,32 +53,36 @@ const ( // Handle returns a Handler function that exposes the provided storage interfaces // as RESTful resources at prefix, serialized by codec, and also includes the support // http resources. -func Handle(storage map[string]RESTStorage, codec runtime.Codec, prefix string, selfLinker runtime.SelfLinker) http.Handler { - group := NewAPIGroup(storage, codec, prefix, selfLinker) - - mux := http.NewServeMux() - group.InstallREST(mux, prefix) - InstallSupport(mux) +func Handle(storage map[string]RESTStorage, codec runtime.Codec, root string, version string, selfLinker runtime.SelfLinker) http.Handler { + prefix := root + "/" + version + group := NewAPIGroupVersion(storage, codec, prefix, selfLinker) + container := restful.NewContainer() + mux := container.ServeMux + group.InstallREST(container, root, version) + ws := new(restful.WebService) + InstallSupport(container, ws) + container.Add(ws) return &defaultAPIServer{mux, group} } -// APIGroup is a http.Handler that exposes multiple RESTStorage objects +// TODO: This is a whole API version right now. Maybe should rename it. +// APIGroupVersion is a http.Handler that exposes multiple RESTStorage objects // It handles URLs of the form: // /${storage_key}[/${object_name}] // Where 'storage_key' points to a RESTStorage object stored in storage. // // TODO: consider migrating this to go-restful which is a more full-featured version of the same thing. -type APIGroup struct { +type APIGroupVersion struct { handler RESTHandler } -// NewAPIGroup returns an object that will serve a set of REST resources and their +// NewAPIGroupVersion returns an object that will serve a set of REST resources and their // associated operations. The provided codec controls serialization and deserialization. // This is a helper method for registering multiple sets of REST handlers under different // prefixes onto a server. // TODO: add multitype codec serialization -func NewAPIGroup(storage map[string]RESTStorage, codec runtime.Codec, canonicalPrefix string, selfLinker runtime.SelfLinker) *APIGroup { - return &APIGroup{RESTHandler{ +func NewAPIGroupVersion(storage map[string]RESTStorage, codec runtime.Codec, canonicalPrefix string, selfLinker runtime.SelfLinker) *APIGroupVersion { + return &APIGroupVersion{RESTHandler{ storage: storage, codec: codec, canonicalPrefix: canonicalPrefix, @@ -85,70 +93,196 @@ func NewAPIGroup(storage map[string]RESTStorage, codec runtime.Codec, canonicalP }} } -func InstallValidator(mux Mux, servers map[string]Server) { - validator, err := NewValidator(servers) - if err != nil { - glog.Errorf("failed to set up validator: %v", err) - return - } - mux.Handle("/validate", validator) +// This magic incantation returns *ptrToObject for an arbitrary pointer +func indirectArbitraryPointer(ptrToObject interface{}) interface{} { + return reflect.Indirect(reflect.ValueOf(ptrToObject)).Interface() } -// InstallREST registers the REST handlers (storage, watch, and operations) into a mux. -// It is expected that the provided prefix will serve all operations. Path MUST NOT end -// in a slash. -func (g *APIGroup) InstallREST(mux Mux, paths ...string) { +func registerResourceHandlers(ws *restful.WebService, version string, path string, storage RESTStorage, kinds map[string]reflect.Type, h restful.RouteFunction) { + glog.V(3).Infof("Installing /%s/%s\n", version, path) + object := storage.New() + _, kind, err := api.Scheme.ObjectVersionAndKind(object) + if err != nil { + glog.Warningf("error getting kind: %v\n", err) + return + } + versionedPtr, err := api.Scheme.New(version, kind) + if err != nil { + glog.Warningf("error making object: %v\n", err) + return + } + versionedObject := indirectArbitraryPointer(versionedPtr) + glog.V(3).Infoln("type: ", reflect.TypeOf(versionedObject)) + + // See github.com/emicklei/go-restful/blob/master/jsr311.go for routing logic + // and status-code behavior + + ws.Route(ws.POST(path).To(h). + Doc("create a " + kind). + Operation("create" + kind). + Reads(versionedObject)) // from the request + + // TODO: This seems like a hack. Add NewList() to storage? + listKind := kind + "List" + if _, ok := kinds[listKind]; !ok { + glog.V(1).Infof("no list type: %v\n", listKind) + } else { + versionedListPtr, err := api.Scheme.New(version, listKind) + if err != nil { + glog.Errorf("error making list: %v\n", err) + } else { + versionedList := indirectArbitraryPointer(versionedListPtr) + glog.V(3).Infoln("type: ", reflect.TypeOf(versionedList)) + ws.Route(ws.GET(path).To(h). + Doc("list objects of kind "+kind). + Operation("list"+kind). + Returns(http.StatusOK, "OK", versionedList)) + } + } + + ws.Route(ws.GET(path + "/{name}").To(h). + Doc("read the specified " + kind). + Operation("read" + kind). + Param(ws.PathParameter("name", "name of the "+kind).DataType("string")). + Writes(versionedObject)) // on the response + + ws.Route(ws.PUT(path + "/{name}").To(h). + Doc("update the specified " + kind). + Operation("update" + kind). + Param(ws.PathParameter("name", "name of the "+kind).DataType("string")). + Reads(versionedObject)) // from the request + + // TODO: Support PATCH + + ws.Route(ws.DELETE(path + "/{name}").To(h). + Doc("delete the specified " + kind). + Operation("delete" + kind). + Param(ws.PathParameter("name", "name of the "+kind).DataType("string"))) +} + +// InstallREST registers the REST handlers (storage, watch, and operations) into a restful Container. +// It is expected that the provided path root prefix will serve all operations. Root MUST NOT end +// in a slash. A restful WebService is created for the group and version. +func (g *APIGroupVersion) InstallREST(container *restful.Container, root string, version string) { + prefix := path.Join(root, version) restHandler := &g.handler + strippedHandler := http.StripPrefix(prefix, restHandler) watchHandler := &WatchHandler{ storage: g.handler.storage, codec: g.handler.codec, canonicalPrefix: g.handler.canonicalPrefix, selfLinker: g.handler.selfLinker, } + proxyHandler := &ProxyHandler{prefix + "/proxy/", g.handler.storage, g.handler.codec} redirectHandler := &RedirectHandler{g.handler.storage, g.handler.codec} opHandler := &OperationHandler{g.handler.ops, g.handler.codec} - for _, prefix := range paths { - prefix = strings.TrimRight(prefix, "/") - proxyHandler := &ProxyHandler{prefix + "/proxy/", g.handler.storage, g.handler.codec} - mux.Handle(prefix+"/", http.StripPrefix(prefix, restHandler)) - // Note: update GetAttribs() when adding a handler. - mux.Handle(prefix+"/watch/", http.StripPrefix(prefix+"/watch/", watchHandler)) - mux.Handle(prefix+"/proxy/", http.StripPrefix(prefix+"/proxy/", proxyHandler)) - mux.Handle(prefix+"/redirect/", http.StripPrefix(prefix+"/redirect/", redirectHandler)) - mux.Handle(prefix+"/operations", http.StripPrefix(prefix+"/operations", opHandler)) - mux.Handle(prefix+"/operations/", http.StripPrefix(prefix+"/operations/", opHandler)) + // Create a new WebService for this APIGroupVersion at the specified path prefix + // TODO: Pass in more descriptive documentation + ws := new(restful.WebService) + ws.Path(prefix) + ws.Doc("API at " + root + ", version " + version) + // TODO: change to restful.MIME_JSON when we convert YAML->JSON and set content type in client + ws.Consumes("*/*") + ws.Produces(restful.MIME_JSON) + // TODO: require json on input + //ws.Consumes(restful.MIME_JSON) + + // TODO: add scheme to APIGroupVersion rather than using api.Scheme + + kinds := api.Scheme.KnownTypes(version) + glog.V(4).Infof("InstallREST: %v kinds: %#v", version, kinds) + + // TODO: #2057: Return API resources on "/". + + // TODO: Add status documentation using Returns() + // Errors (see api/errors/errors.go as well as go-restful router): + // http.StatusNotFound, http.StatusMethodNotAllowed, + // http.StatusUnsupportedMediaType, http.StatusNotAcceptable, + // http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, + // http.StatusRequestTimeout, http.StatusConflict, http.StatusPreconditionFailed, + // 422 (StatusUnprocessableEntity), http.StatusInternalServerError, + // http.StatusServiceUnavailable + // and api error codes + // Note that if we specify a versioned Status object here, we may need to + // create one for the tests, also + // Success: + // http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent + // + // test/integration/auth_test.go is currently the most comprehensive status code test + + // TODO: eliminate all the restful wrappers + // TODO: create a separate handler per verb + h := func(req *restful.Request, resp *restful.Response) { + glog.V(4).Infof("User-Agent: %s\n", req.HeaderParameter("User-Agent")) + strippedHandler.ServeHTTP(resp.ResponseWriter, req.Request) + } + + for path, storage := range g.handler.storage { + registerResourceHandlers(ws, version, path, storage, kinds, h) + } + + // TODO: port the rest of these. Sadly, if we don't, we'll have inconsistent + // API behavior, as well as lack of documentation + mux := container.ServeMux + + // Note: update GetAttribs() when adding a handler. + mux.Handle(prefix+"/watch/", http.StripPrefix(prefix+"/watch/", watchHandler)) + mux.Handle(prefix+"/proxy/", http.StripPrefix(prefix+"/proxy/", proxyHandler)) + mux.Handle(prefix+"/redirect/", http.StripPrefix(prefix+"/redirect/", redirectHandler)) + mux.Handle(prefix+"/operations", http.StripPrefix(prefix+"/operations", opHandler)) + mux.Handle(prefix+"/operations/", http.StripPrefix(prefix+"/operations/", opHandler)) + + container.Add(ws) +} + +// TODO: Convert to go-restful +func InstallValidator(mux Mux, servers map[string]Server) { + validator, err := NewValidator(servers) + if err != nil { + glog.Errorf("failed to set up validator: %v", err) + return + } + if validator != nil { + mux.Handle("/validate", validator) } } -// InstallSupport registers the APIServer support functions into a mux. -func InstallSupport(mux Mux) { - healthz.InstallHandler(mux) - mux.HandleFunc("/version", handleVersion) - mux.HandleFunc("/", handleIndex) +// TODO: document all handlers +// InstallSupport registers the APIServer support functions +func InstallSupport(container *restful.Container, ws *restful.WebService) { + // TODO: convert healthz to restful and remove container arg + healthz.InstallHandler(container.ServeMux) + ws.Route(ws.GET("/").To(handleIndex)) + ws.Route(ws.GET("/version").To(handleVersion)) } // InstallLogsSupport registers the APIServer log support function into a mux. func InstallLogsSupport(mux Mux) { + // TODO: use restful: ws.Route(ws.GET("/logs/{logpath:*}").To(fileHandler)) + // See github.com/emicklei/go-restful/blob/master/examples/restful-serve-static.go mux.Handle("/logs/", http.StripPrefix("/logs/", http.FileServer(http.Dir("/var/log/")))) } // handleVersion writes the server's version information. -func handleVersion(w http.ResponseWriter, req *http.Request) { - writeRawJSON(http.StatusOK, version.Get(), w) +func handleVersion(req *restful.Request, resp *restful.Response) { + // TODO: use restful's Response methods + writeRawJSON(http.StatusOK, version.Get(), resp.ResponseWriter) } // APIVersionHandler returns a handler which will list the provided versions as available. -func APIVersionHandler(versions ...string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - writeRawJSON(http.StatusOK, version.APIVersions{Versions: versions}, w) - }) +func APIVersionHandler(versions ...string) restful.RouteFunction { + return func(req *restful.Request, resp *restful.Response) { + // TODO: use restful's Response methods + writeRawJSON(http.StatusOK, api.APIVersions{Versions: versions}, resp.ResponseWriter) + } } // writeJSON renders an object as JSON to the response. func writeJSON(statusCode int, codec runtime.Codec, object runtime.Object, w http.ResponseWriter) { output, err := codec.Encode(object) if err != nil { + // Note: If codec is broken, this results in an infinite recursion errorJSON(err, codec, w) return } diff --git a/pkg/apiserver/apiserver_test.go b/pkg/apiserver/apiserver_test.go index e01d4516d4b..d38a9b7117b 100644 --- a/pkg/apiserver/apiserver_test.go +++ b/pkg/apiserver/apiserver_test.go @@ -32,8 +32,7 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/pkg/api" apierrs "github.com/GoogleCloudPlatform/kubernetes/pkg/api/errors" - "github.com/GoogleCloudPlatform/kubernetes/pkg/api/latest" - "github.com/GoogleCloudPlatform/kubernetes/pkg/api/testapi" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api/meta" "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" "github.com/GoogleCloudPlatform/kubernetes/pkg/util" @@ -45,12 +44,54 @@ func convert(obj runtime.Object) (runtime.Object, error) { return obj, nil } -var codec = testapi.Codec() -var selfLinker = latest.SelfLinker +// This creates a fake API version, similar to api/latest.go +const testVersion = "version" + +var versions = []string{testVersion} +var codec = runtime.CodecFor(api.Scheme, testVersion) +var accessor = meta.NewAccessor() +var versioner runtime.ResourceVersioner = accessor +var selfLinker runtime.SelfLinker = accessor +var mapper meta.RESTMapper + +func interfacesFor(version string) (*meta.VersionInterfaces, error) { + switch version { + case testVersion: + return &meta.VersionInterfaces{ + Codec: codec, + ObjectConvertor: api.Scheme, + MetadataAccessor: accessor, + }, nil + default: + return nil, fmt.Errorf("unsupported storage version: %s (valid: %s)", version, strings.Join(versions, ", ")) + } +} func init() { - api.Scheme.AddKnownTypes("", &Simple{}, &SimpleList{}) - api.Scheme.AddKnownTypes(testapi.Version(), &Simple{}, &SimpleList{}) + // Certain API objects are returned regardless of the contents of storage: + // api.Status is returned in errors + // api.ServerOp/api.ServerOpList are returned by /operations + + // "internal" version + api.Scheme.AddKnownTypes("", &Simple{}, &SimpleList{}, + &api.Status{}, &api.ServerOp{}, &api.ServerOpList{}) + // "version" version + // TODO: Use versioned api objects? + api.Scheme.AddKnownTypes(testVersion, &Simple{}, &SimpleList{}, + &api.Status{}, &api.ServerOp{}, &api.ServerOpList{}) + + defMapper := meta.NewDefaultRESTMapper( + versions, + func(version string) (*meta.VersionInterfaces, bool) { + interfaces, err := interfacesFor(version) + if err != nil { + return nil, false + } + return interfaces, true + }, + ) + defMapper.Add(api.Scheme, true, versions...) + mapper = defMapper } type Simple struct { @@ -204,23 +245,24 @@ func TestNotFound(t *testing.T) { type T struct { Method string Path string + Status int } cases := map[string]T{ - "PATCH method": {"PATCH", "/prefix/version/foo"}, - "GET long prefix": {"GET", "/prefix/"}, - "GET missing storage": {"GET", "/prefix/version/blah"}, - "GET with extra segment": {"GET", "/prefix/version/foo/bar/baz"}, - "POST with extra segment": {"POST", "/prefix/version/foo/bar"}, - "DELETE without extra segment": {"DELETE", "/prefix/version/foo"}, - "DELETE with extra segment": {"DELETE", "/prefix/version/foo/bar/baz"}, - "PUT without extra segment": {"PUT", "/prefix/version/foo"}, - "PUT with extra segment": {"PUT", "/prefix/version/foo/bar/baz"}, - "watch missing storage": {"GET", "/prefix/version/watch/"}, - "watch with bad method": {"POST", "/prefix/version/watch/foo/bar"}, + "PATCH method": {"PATCH", "/prefix/version/foo", http.StatusMethodNotAllowed}, + "GET long prefix": {"GET", "/prefix/", http.StatusNotFound}, + "GET missing storage": {"GET", "/prefix/version/blah", http.StatusNotFound}, + "GET with extra segment": {"GET", "/prefix/version/foo/bar/baz", http.StatusNotFound}, + "POST with extra segment": {"POST", "/prefix/version/foo/bar", http.StatusMethodNotAllowed}, + "DELETE without extra segment": {"DELETE", "/prefix/version/foo", http.StatusMethodNotAllowed}, + "DELETE with extra segment": {"DELETE", "/prefix/version/foo/bar/baz", http.StatusNotFound}, + "PUT without extra segment": {"PUT", "/prefix/version/foo", http.StatusMethodNotAllowed}, + "PUT with extra segment": {"PUT", "/prefix/version/foo/bar/baz", http.StatusNotFound}, + "watch missing storage": {"GET", "/prefix/version/watch/", http.StatusNotFound}, + "watch with bad method": {"POST", "/prefix/version/watch/foo/bar", http.StatusNotFound}, } handler := Handle(map[string]RESTStorage{ "foo": &SimpleRESTStorage{}, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() client := http.Client{} @@ -235,14 +277,14 @@ func TestNotFound(t *testing.T) { t.Errorf("unexpected error: %v", err) } - if response.StatusCode != http.StatusNotFound { - t.Errorf("Expected %d for %s (%s), Got %#v", http.StatusNotFound, v, k, response) + if response.StatusCode != v.Status { + t.Errorf("Expected %d for %s (%s), Got %#v", v.Status, v, k, response) } } } func TestVersion(t *testing.T) { - handler := Handle(map[string]RESTStorage{}, codec, "/prefix/version", selfLinker) + handler := Handle(map[string]RESTStorage{}, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() client := http.Client{} @@ -276,7 +318,7 @@ func TestSimpleList(t *testing.T) { t: t, expectedSet: "/prefix/version/simple", } - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -299,7 +341,7 @@ func TestErrorList(t *testing.T) { errors: map[string]error{"list": fmt.Errorf("test Error")}, } storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -309,7 +351,7 @@ func TestErrorList(t *testing.T) { } if resp.StatusCode != http.StatusInternalServerError { - t.Errorf("Unexpected status: %d, Expected: %d, %#v", resp.StatusCode, http.StatusOK, resp) + t.Errorf("Unexpected status: %d, Expected: %d, %#v", resp.StatusCode, http.StatusInternalServerError, resp) } } @@ -324,7 +366,7 @@ func TestNonEmptyList(t *testing.T) { }, } storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -366,7 +408,7 @@ func TestGet(t *testing.T) { expectedSet: "/prefix/version/simple/id", } storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -391,7 +433,7 @@ func TestGetMissing(t *testing.T) { errors: map[string]error{"get": apierrs.NewNotFound("simple", "id")}, } storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -410,7 +452,7 @@ func TestDelete(t *testing.T) { simpleStorage := SimpleRESTStorage{} ID := "id" storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -433,7 +475,7 @@ func TestDeleteMissing(t *testing.T) { errors: map[string]error{"delete": apierrs.NewNotFound("simple", ID)}, } storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -458,7 +500,7 @@ func TestUpdate(t *testing.T) { t: t, expectedSet: "/prefix/version/simple/" + ID, } - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -467,7 +509,8 @@ func TestUpdate(t *testing.T) { } body, err := codec.Encode(item) if err != nil { - t.Errorf("unexpected error: %v", err) + // The following cases will fail, so die now + t.Fatalf("unexpected error: %v", err) } client := http.Client{} @@ -477,7 +520,7 @@ func TestUpdate(t *testing.T) { t.Errorf("unexpected error: %v", err) } - if simpleStorage.updated.Name != item.Name { + if simpleStorage.updated == nil || simpleStorage.updated.Name != item.Name { t.Errorf("Unexpected update value %#v, expected %#v.", simpleStorage.updated, item) } if !selfLinker.called { @@ -492,7 +535,7 @@ func TestUpdateMissing(t *testing.T) { errors: map[string]error{"update": apierrs.NewNotFound("simple", ID)}, } storage["simple"] = &simpleStorage - handler := Handle(storage, codec, "/prefix/version", selfLinker) + handler := Handle(storage, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -527,7 +570,7 @@ func TestCreate(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", testVersion, selfLinker) handler.(*defaultAPIServer).group.handler.asyncOpWait = 0 server := httptest.NewServer(handler) defer server.Close() @@ -570,7 +613,7 @@ func TestCreateNotFound(t *testing.T) { // See https://github.com/GoogleCloudPlatform/kubernetes/pull/486#discussion_r15037092. errors: map[string]error{"create": apierrs.NewNotFound("simple", "id")}, }, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() client := http.Client{} @@ -635,7 +678,7 @@ func TestSyncCreate(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": &storage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() client := http.Client{} @@ -708,7 +751,7 @@ func TestAsyncDelayReturnsError(t *testing.T) { return nil, apierrs.NewAlreadyExists("foo", "bar") }, } - handler := Handle(map[string]RESTStorage{"foo": &storage}, codec, "/prefix/version", selfLinker) + handler := Handle(map[string]RESTStorage{"foo": &storage}, codec, "/prefix", testVersion, selfLinker) handler.(*defaultAPIServer).group.handler.asyncOpWait = time.Millisecond / 2 server := httptest.NewServer(handler) defer server.Close() @@ -732,7 +775,7 @@ func TestAsyncCreateError(t *testing.T) { name: "bar", expectedSet: "/prefix/version/foo/bar", } - handler := Handle(map[string]RESTStorage{"foo": &storage}, codec, "/prefix/version", selfLinker) + handler := Handle(map[string]RESTStorage{"foo": &storage}, codec, "/prefix", testVersion, selfLinker) handler.(*defaultAPIServer).group.handler.asyncOpWait = 0 server := httptest.NewServer(handler) defer server.Close() @@ -784,7 +827,7 @@ func (*UnregisteredAPIObject) IsAnAPIObject() {} func TestWriteJSONDecodeError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - writeJSON(http.StatusOK, latest.Codec, &UnregisteredAPIObject{"Undecodable"}, w) + writeJSON(http.StatusOK, codec, &UnregisteredAPIObject{"Undecodable"}, w) })) defer server.Close() status := expectApiStatus(t, "GET", server.URL, nil, http.StatusInternalServerError) @@ -832,7 +875,7 @@ func TestSyncCreateTimeout(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": &storage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", testVersion, selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -864,7 +907,7 @@ func TestCORSAllowedOrigins(t *testing.T) { } handler := CORS( - Handle(map[string]RESTStorage{}, codec, "/prefix/version", selfLinker), + Handle(map[string]RESTStorage{}, codec, "/prefix", testVersion, selfLinker), allowedOriginRegexps, nil, nil, "true", ) server := httptest.NewServer(handler) diff --git a/pkg/apiserver/handlers.go b/pkg/apiserver/handlers.go index b82f9384db9..a853050583c 100644 --- a/pkg/apiserver/handlers.go +++ b/pkg/apiserver/handlers.go @@ -121,6 +121,7 @@ func RecoverPanics(handler http.Handler) http.Handler { }) } +// TODO: use restful.CrossOriginResourceSharing // Simple CORS implementation that wraps an http Handler // For a more detailed implementation use https://github.com/martini-contrib/cors // or implement CORS at your proxy layer diff --git a/pkg/apiserver/index.go b/pkg/apiserver/index.go index fa46ba9e070..85b27b18ff5 100644 --- a/pkg/apiserver/index.go +++ b/pkg/apiserver/index.go @@ -19,16 +19,19 @@ package apiserver import ( "fmt" "net/http" + + "github.com/emicklei/go-restful" ) // 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) +func handleIndex(req *restful.Request, resp *restful.Response) { + // TODO: use restful's Request/Response methods + if req.Request.URL.Path != "/" && req.Request.URL.Path != "/index.html" { + notFound(resp.ResponseWriter, req.Request) return } - w.WriteHeader(http.StatusOK) - // TODO: serve this out of a file? + resp.ResponseWriter.WriteHeader(http.StatusOK) + // TODO: serve this out of a file data := "Welcome to Kubernetes" - fmt.Fprint(w, data) + fmt.Fprint(resp.ResponseWriter, data) } diff --git a/pkg/apiserver/operation_test.go b/pkg/apiserver/operation_test.go index 5faf3b12a3b..48e8f6820a5 100644 --- a/pkg/apiserver/operation_test.go +++ b/pkg/apiserver/operation_test.go @@ -113,7 +113,7 @@ func TestOperationsList(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", "version", selfLinker) handler.(*defaultAPIServer).group.handler.asyncOpWait = 0 server := httptest.NewServer(handler) defer server.Close() @@ -170,7 +170,7 @@ func TestOpGet(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", "version", selfLinker) handler.(*defaultAPIServer).group.handler.asyncOpWait = 0 server := httptest.NewServer(handler) defer server.Close() diff --git a/pkg/apiserver/proxy_test.go b/pkg/apiserver/proxy_test.go index 651b65dd170..4213ddbba7c 100644 --- a/pkg/apiserver/proxy_test.go +++ b/pkg/apiserver/proxy_test.go @@ -165,7 +165,7 @@ func TestProxy(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", "version", selfLinker) server := httptest.NewServer(handler) defer server.Close() diff --git a/pkg/apiserver/redirect_test.go b/pkg/apiserver/redirect_test.go index 2a56a9c4a47..70bce205083 100644 --- a/pkg/apiserver/redirect_test.go +++ b/pkg/apiserver/redirect_test.go @@ -31,7 +31,7 @@ func TestRedirect(t *testing.T) { } handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/prefix/version", selfLinker) + }, codec, "/prefix", "version", selfLinker) server := httptest.NewServer(handler) defer server.Close() diff --git a/pkg/apiserver/watch_test.go b/pkg/apiserver/watch_test.go index bb37d9ca7a0..e5dff97ebb7 100644 --- a/pkg/apiserver/watch_test.go +++ b/pkg/apiserver/watch_test.go @@ -50,7 +50,7 @@ func TestWatchWebsocket(t *testing.T) { _ = ResourceWatcher(simpleStorage) // Give compile error if this doesn't work. handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/api/version", selfLinker) + }, codec, "/api", "version", selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -104,7 +104,7 @@ func TestWatchHTTP(t *testing.T) { simpleStorage := &SimpleRESTStorage{} handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/api/version", selfLinker) + }, codec, "/api", "version", selfLinker) server := httptest.NewServer(handler) defer server.Close() client := http.Client{} @@ -167,7 +167,7 @@ func TestWatchParamParsing(t *testing.T) { simpleStorage := &SimpleRESTStorage{} handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/api/version", selfLinker) + }, codec, "/api", "version", selfLinker) server := httptest.NewServer(handler) defer server.Close() @@ -239,7 +239,7 @@ func TestWatchProtocolSelection(t *testing.T) { simpleStorage := &SimpleRESTStorage{} handler := Handle(map[string]RESTStorage{ "foo": simpleStorage, - }, codec, "/api/version", selfLinker) + }, codec, "/api", "version", selfLinker) server := httptest.NewServer(handler) defer server.Close() defer server.CloseClientConnections() diff --git a/pkg/client/client.go b/pkg/client/client.go index 8af1d7e49d8..87a3586ea5a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -63,7 +63,7 @@ func (c *Client) Services(namespace string) ServiceInterface { // VersionInterface has a method to retrieve the server version. type VersionInterface interface { ServerVersion() (*version.Info, error) - ServerAPIVersions() (*version.APIVersions, error) + ServerAPIVersions() (*api.APIVersions, error) } // APIStatus is exposed by errors that can be converted to an api.Status object @@ -92,12 +92,12 @@ func (c *Client) ServerVersion() (*version.Info, error) { } // ServerAPIVersions retrieves and parses the list of API versions the server supports. -func (c *Client) ServerAPIVersions() (*version.APIVersions, error) { +func (c *Client) ServerAPIVersions() (*api.APIVersions, error) { body, err := c.Get().AbsPath("/api").Do().Raw() if err != nil { return nil, err } - var v version.APIVersions + var v api.APIVersions err = json.Unmarshal(body, &v) if err != nil { return nil, fmt.Errorf("Got '%s': %v", string(body), err) diff --git a/pkg/client/fake.go b/pkg/client/fake.go index 793c8d216fa..00c81c1bb05 100644 --- a/pkg/client/fake.go +++ b/pkg/client/fake.go @@ -71,7 +71,7 @@ func (c *Fake) ServerVersion() (*version.Info, error) { return &versionInfo, nil } -func (c *Fake) ServerAPIVersions() (*version.APIVersions, error) { +func (c *Fake) ServerAPIVersions() (*api.APIVersions, error) { c.Actions = append(c.Actions, FakeAction{Action: "get-apiversions", Value: nil}) - return &version.APIVersions{Versions: []string{"v1beta1", "v1beta2"}}, nil + return &api.APIVersions{Versions: []string{"v1beta1", "v1beta2"}}, nil } diff --git a/pkg/master/handlers.go b/pkg/master/handlers.go index b23843b0b38..3cff68028e1 100644 --- a/pkg/master/handlers.go +++ b/pkg/master/handlers.go @@ -20,20 +20,24 @@ import ( "net/http" "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authenticator" + + "github.com/emicklei/go-restful" ) // handleWhoAmI returns the user-string which this request is authenticated as (if any). // Useful for debugging authentication. Always returns HTTP status okay and a human // readable (not intended as API) description of authentication state of request. -func handleWhoAmI(auth authenticator.Request) func(w http.ResponseWriter, req *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { +func handleWhoAmI(auth authenticator.Request) restful.RouteFunction { + return func(req *restful.Request, resp *restful.Response) { + // This is supposed to go away, so it's not worth the effort to convert to restful + w := resp.ResponseWriter w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) if auth == nil { w.Write([]byte("NO AUTHENTICATION SUPPORT")) return } - userInfo, ok, err := auth.AuthenticateRequest(req) + userInfo, ok, err := auth.AuthenticateRequest(req.Request) if err != nil { w.Write([]byte("ERROR WHILE AUTHENTICATING")) return diff --git a/pkg/master/master.go b/pkg/master/master.go index b79671310d4..d5aeaa80afc 100644 --- a/pkg/master/master.go +++ b/pkg/master/master.go @@ -17,10 +17,13 @@ limitations under the License. package master import ( + "bytes" + _ "expvar" "fmt" "net" "net/http" "net/url" + rt "runtime" "strconv" "strings" "time" @@ -51,6 +54,8 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/pkg/ui" "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/emicklei/go-restful" + "github.com/emicklei/go-restful/swagger" "github.com/golang/glog" ) @@ -64,7 +69,6 @@ type Config struct { MinionRegexp string KubeletClient client.KubeletClient PortalNet *net.IPNet - Mux apiserver.Mux EnableLogsSupport bool EnableUISupport bool APIPrefix string @@ -101,6 +105,8 @@ type Master struct { client *client.Client portalNet *net.IPNet mux apiserver.Mux + handlerContainer *restful.Container + rootWebService *restful.WebService enableLogsSupport bool enableUISupport bool apiPrefix string @@ -218,6 +224,7 @@ func New(c *Config) *Master { if c.KubeletClient == nil { glog.Fatalf("master.New() called with config.KubeletClient == nil") } + mx := http.NewServeMux() m := &Master{ podRegistry: etcd.NewRegistry(c.EtcdHelper, boundPodFactory), controllerRegistry: etcd.NewRegistry(c.EtcdHelper, nil), @@ -228,7 +235,9 @@ func New(c *Config) *Master { minionRegistry: minionRegistry, client: c.Client, portalNet: c.PortalNet, - mux: http.NewServeMux(), + mux: mx, + handlerContainer: NewHandlerContainer(mx), + rootWebService: new(restful.WebService), enableLogsSupport: c.EnableLogsSupport, enableUISupport: c.EnableUISupport, apiPrefix: c.APIPrefix, @@ -253,6 +262,7 @@ func (m *Master) HandleWithAuth(pattern string, handler http.Handler) { // URLs into attributes that an Authorizer can understand, and have // sensible policy defaults for plugged-in endpoints. This will be different // for generic endpoints versus REST object endpoints. + // TODO: convert to go-restful m.mux.Handle(pattern, handler) } @@ -260,9 +270,31 @@ func (m *Master) HandleWithAuth(pattern string, handler http.Handler) { // Applies the same authentication and authorization (if any is configured) // to the request is used for the master's built-in endpoints. func (m *Master) HandleFuncWithAuth(pattern string, handler func(http.ResponseWriter, *http.Request)) { + // TODO: convert to go-restful m.mux.HandleFunc(pattern, handler) } +func NewHandlerContainer(mux *http.ServeMux) *restful.Container { + container := restful.NewContainer() + container.ServeMux = mux + container.RecoverHandler(logStackOnRecover) + return container +} + +//TODO: Unify with RecoverPanics? +func logStackOnRecover(panicReason interface{}, httpWriter http.ResponseWriter) { + var buffer bytes.Buffer + buffer.WriteString(fmt.Sprintf("recover from panic situation: - %v\r\n", panicReason)) + for i := 2; ; i += 1 { + _, file, line, ok := rt.Caller(i) + if !ok { + break + } + buffer.WriteString(fmt.Sprintf(" %s:%d\r\n", file, line)) + } + glog.Errorln(buffer.String()) +} + func makeMinionRegistry(c *Config) minion.Registry { var minionRegistry minion.Registry = etcd.NewRegistry(c.EtcdHelper, nil) if c.HealthCheckMinions { @@ -286,6 +318,7 @@ func (m *Master) init(c *Config) { authenticator = bearertoken.New(tokenAuthenticator) } + // TODO: Factor out the core API registration m.storage = map[string]apiserver.RESTStorage{ "pods": pod.NewREST(&pod.RESTConfig{ CloudProvider: c.Cloud, @@ -304,13 +337,17 @@ func (m *Master) init(c *Config) { "bindings": binding.NewREST(m.bindingRegistry), } - apiserver.NewAPIGroup(m.API_v1beta1()).InstallREST(m.mux, c.APIPrefix+"/v1beta1") - apiserver.NewAPIGroup(m.API_v1beta2()).InstallREST(m.mux, c.APIPrefix+"/v1beta2") - versionHandler := apiserver.APIVersionHandler("v1beta1", "v1beta2") - m.mux.Handle(c.APIPrefix, versionHandler) - apiserver.InstallSupport(m.mux) - serversToValidate := m.getServersToValidate(c) + apiserver.NewAPIGroupVersion(m.API_v1beta1()).InstallREST(m.handlerContainer, c.APIPrefix, "v1beta1") + apiserver.NewAPIGroupVersion(m.API_v1beta2()).InstallREST(m.handlerContainer, c.APIPrefix, "v1beta2") + // TODO: InstallREST should register each version automatically + versionHandler := apiserver.APIVersionHandler("v1beta1", "v1beta2") + m.rootWebService.Route(m.rootWebService.GET(c.APIPrefix).To(versionHandler)) + + apiserver.InstallSupport(m.handlerContainer, m.rootWebService) + + // TODO: use go-restful + serversToValidate := m.getServersToValidate(c) apiserver.InstallValidator(m.mux, serversToValidate) if c.EnableLogsSupport { apiserver.InstallLogsSupport(m.mux) @@ -319,8 +356,15 @@ func (m *Master) init(c *Config) { ui.InstallSupport(m.mux) } + // TODO: install runtime/pprof handler + // See github.com/emicklei/go-restful/blob/master/examples/restful-cpuprofiler-service.go + handler := http.Handler(m.mux.(*http.ServeMux)) + // TODO: handle CORS and auth using go-restful + // See github.com/emicklei/go-restful/blob/master/examples/restful-CORS-filter.go, and + // github.com/emicklei/go-restful/blob/master/examples/restful-basic-authentication.go + if len(c.CorsAllowedOriginList) > 0 { allowedOriginRegexps, err := util.CompileRegexps(c.CorsAllowedOriginList) if err != nil { @@ -338,7 +382,23 @@ func (m *Master) init(c *Config) { if authenticator != nil { handler = handlers.NewRequestAuthenticator(userContexts, authenticator, handlers.Unauthorized, handler) } - m.mux.HandleFunc("/_whoami", handleWhoAmI(authenticator)) + // TODO: Remove temporary _whoami handler + m.rootWebService.Route(m.rootWebService.GET("/_whoami").To(handleWhoAmI(authenticator))) + + // Install root web services + m.handlerContainer.Add(m.rootWebService) + + // TODO: Make this optional? + // Enable swagger UI and discovery API + swaggerConfig := swagger.Config{ + WebServices: m.handlerContainer.RegisteredWebServices(), + // TODO: Parameterize the path? + ApiPath: "/swaggerapi/", + // TODO: Distribute UI javascript and enable the UI + //SwaggerPath: "/swaggerui/", + //SwaggerFilePath: "/srv/apiserver/swagger/dist" + } + swagger.RegisterSwaggerService(swaggerConfig, m.handlerContainer) m.Handler = handler diff --git a/pkg/standalone/standalone.go b/pkg/standalone/standalone.go index c1e99f9f673..bf8bf85c7a6 100644 --- a/pkg/standalone/standalone.go +++ b/pkg/standalone/standalone.go @@ -96,11 +96,8 @@ func RunApiServer(cl *client.Client, etcdClient tools.EtcdClient, addr string, p ReadOnlyPort: port, PublicAddress: addr, }) - mux := http.NewServeMux() - apiserver.NewAPIGroup(m.API_v1beta1()).InstallREST(mux, "/api/v1beta1") - apiserver.NewAPIGroup(m.API_v1beta2()).InstallREST(mux, "/api/v1beta2") - apiserver.InstallSupport(mux) - handler.delegate = mux + + handler.delegate = m.InsecureHandler go http.ListenAndServe(fmt.Sprintf("%s:%d", addr, port), &handler) } diff --git a/pkg/version/version.go b/pkg/version/version.go index 5110d1a10ba..1343e4bde26 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -45,9 +45,3 @@ func Get() Info { func (info Info) String() string { return info.GitVersion } - -// APIVersions lists the api versions that are available, to allow -// version negotiation. -type APIVersions struct { - Versions []string `json:"versions" yaml:"versions"` -} diff --git a/test/integration/auth_test.go b/test/integration/auth_test.go index 0607b4d0ef7..ec57a4a5d75 100644 --- a/test/integration/auth_test.go +++ b/test/integration/auth_test.go @@ -242,6 +242,7 @@ var code200 = map[int]bool{200: true} var code400 = map[int]bool{400: true} var code403 = map[int]bool{403: true} var code404 = map[int]bool{404: true} +var code405 = map[int]bool{405: true} var code409 = map[int]bool{409: true} var code422 = map[int]bool{422: true} var code500 = map[int]bool{500: true} @@ -269,14 +270,14 @@ func getTestRequests() []struct { // Non-standard methods (not expected to work, // but expected to pass/fail authorization prior to // failing validation. - {"PATCH", "/api/v1beta1/pods/a", "", code404}, - {"OPTIONS", "/api/v1beta1/pods", "", code404}, - {"OPTIONS", "/api/v1beta1/pods/a", "", code404}, - {"HEAD", "/api/v1beta1/pods", "", code404}, - {"HEAD", "/api/v1beta1/pods/a", "", code404}, - {"TRACE", "/api/v1beta1/pods", "", code404}, - {"TRACE", "/api/v1beta1/pods/a", "", code404}, - {"NOSUCHVERB", "/api/v1beta1/pods", "", code404}, + {"PATCH", "/api/v1beta1/pods/a", "", code405}, + {"OPTIONS", "/api/v1beta1/pods", "", code405}, + {"OPTIONS", "/api/v1beta1/pods/a", "", code405}, + {"HEAD", "/api/v1beta1/pods", "", code405}, + {"HEAD", "/api/v1beta1/pods/a", "", code405}, + {"TRACE", "/api/v1beta1/pods", "", code405}, + {"TRACE", "/api/v1beta1/pods/a", "", code405}, + {"NOSUCHVERB", "/api/v1beta1/pods", "", code405}, // Normal methods on services {"GET", "/api/v1beta1/services", "", code200}, @@ -320,12 +321,12 @@ func getTestRequests() []struct { {"DELETE", "/api/v1beta1/events/a" + syncFlags, "", code200}, // Normal methods on bindings - {"GET", "/api/v1beta1/bindings", "", code404}, // Bindings are write-only, so 404 + {"GET", "/api/v1beta1/bindings", "", code405}, // Bindings are write-only {"POST", "/api/v1beta1/pods" + syncFlags, aPod, code200}, // Need a pod to bind or you get a 404 {"POST", "/api/v1beta1/bindings" + syncFlags, aBinding, code200}, {"PUT", "/api/v1beta1/bindings/a" + syncFlags, aBinding, code500}, // See #2114 about why 500 - {"GET", "/api/v1beta1/bindings", "", code404}, - {"GET", "/api/v1beta1/bindings/a", "", code404}, + {"GET", "/api/v1beta1/bindings", "", code405}, + {"GET", "/api/v1beta1/bindings/a", "", code404}, // No bindings instances {"DELETE", "/api/v1beta1/bindings/a" + syncFlags, "", code404}, // Non-existent object type. @@ -340,7 +341,8 @@ func getTestRequests() []struct { {"GET", "/api/v1beta1/operations", "", code200}, {"GET", "/api/v1beta1/operations/1234567890", "", code404}, - // Special verbs on pods + // Special verbs on nodes + // TODO: Will become 405 once these are converted to go-restful {"GET", "/api/v1beta1/proxy/minions/a", "", code404}, {"GET", "/api/v1beta1/redirect/minions/a", "", code404}, // TODO: test .../watch/..., which doesn't end before the test timeout.