Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add login to websocket API #143

Merged
merged 1 commit into from
Jul 6, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dependencies.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ github.com/juju/gomaasapi git 5bd7212f416a2d801e4a39800b66e1ee4461c42e 2016-05-0
github.com/juju/httpprof git 14bf14c307672fd2456bdbf35d19cf0ccd3cf565 2014-12-17T16:00:36Z
github.com/juju/httprequest git 796aaafaf712f666df58d31a482c51233038bf9f 2016-05-03T15:03:27Z
github.com/juju/idmclient git 3dda079a75cccb85083d4c3877e638f5d6ab79c2 2016-05-26T05:00:34Z
github.com/juju/juju git 894de5b4a1d3955f5edd79f19255e41c0ab23d9c 2016-06-30T14:53:07Z
github.com/juju/juju git 50dd7bb3961f0fb8090c438dc111f68efc4e605e 2016-07-01T23:42:52Z
github.com/juju/loggo git 8477fc936adf0e382d680310047ca27e128a309a 2015-05-27T03:58:39Z
github.com/juju/mgoutil git 5f725bbe1f9842b097129570e79441be24012e9c 2016-05-20T10:19:24Z
github.com/juju/mutex git 59c26ee163447c5c57f63ff71610d433862013de 2016-06-17T01:09:07Z
Expand Down
63 changes: 63 additions & 0 deletions internal/apitest/apitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/juju/idmclient/idmtest"
corejujutesting "github.com/juju/juju/juju/testing"
"github.com/juju/testing"
jc "github.com/juju/testing/checkers"
"github.com/juju/testing/httptesting"
gc "gopkg.in/check.v1"
"gopkg.in/macaroon-bakery.v1/bakery"
Expand Down Expand Up @@ -127,6 +128,68 @@ func (s *Suite) NewServer(c *gc.C, session *mgo.Session, idmSrv *idmtest.Server)
return srv.(*jemserver.Server)
}

// AssertAddController adds the specified controller using AddController
// and checks that id succeeds. It returns the controller id.
func (s *Suite) AssertAddController(c *gc.C, path params.EntityPath, loc map[string]string) params.EntityPath {
err := s.AddController(c, path, loc)
c.Assert(err, jc.ErrorIsNil)
return path
}

// AddController adds a new controller with the provided path and any
// specified location parameters.
func (s *Suite) AddController(c *gc.C, path params.EntityPath, loc map[string]string) error {
// Note that because the cookies acquired in this request don't
// persist, the discharge macaroon we get won't affect subsequent
// requests in the caller.
info := s.APIInfo(c)
p := &params.AddController{
EntityPath: path,
Info: params.ControllerInfo{
HostPorts: info.Addrs,
CACert: info.CACert,
User: info.Tag.Id(),
Password: info.Password,
ControllerUUID: info.ModelTag.Id(),
Location: loc,
},
}
// If locations are specified then make the controller public so
// that it can be found by seraching on those locations.
if len(loc) > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the controller is public and a user is added if len(loc) != 0?
Could you please add a comment?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

p.Info.Public = true
s.IDMSrv.AddUser(string(path.User), "controller-admin")
}
return s.NewClient(path.User).AddController(p)
}

const dummySSHKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDOjaOjVRHchF2RFCKQdgBqrIA5nOoqSprLK47l2th5I675jw+QYMIihXQaITss3hjrh3+5ITyBO41PS5rHLNGtlYUHX78p9CHNZsJqHl/z1Ub1tuMe+/5SY2MkDYzgfPtQtVsLasAIiht/5g78AMMXH3HeCKb9V9cP6/lPPq6mCMvg8TDLrPp/P2vlyukAsJYUvVgoaPDUBpedHbkMj07pDJqe4D7c0yEJ8hQo/6nS+3bh9Q1NvmVNsB1pbtk3RKONIiTAXYcjclmOljxxJnl1O50F5sOIi38vyl7Q63f6a3bXMvJEf1lnPNJKAxspIfEu8gRasny3FEsbHfrxEwVj rog@rog-x220"

var dummyModelConfig = map[string]interface{}{
"authorized-keys": dummySSHKey,
"controller": true,
}

// CreateModel creates a new model with the specified path on the
// specified controller, using the specified templates. It returns the
// new model's path, user and uuid.
func (s *Suite) CreateModel(c *gc.C, path, ctlPath params.EntityPath, templates ...params.EntityPath) (modelPath params.EntityPath, user, uuid string) {
// Note that because the cookies acquired in this request don't
// persist, the discharge macaroon we get won't affect subsequent
// requests in the caller.
resp, err := s.NewClient(path.User).NewModel(&params.NewModel{
User: path.User,
Info: params.NewModelInfo{
Name: path.Name,
Controller: &ctlPath,
Config: dummyModelConfig,
TemplatePaths: templates,
},
})
c.Assert(err, gc.IsNil)
return resp.Path, resp.User, resp.UUID
}

// Do returns a Do function appropriate for using in httptesting.AssertJSONCall.Do
// that makes its HTTP request acting as the given client.
// If client is nil, it uses httpbakery.NewClient instead.
Expand Down
15 changes: 15 additions & 0 deletions internal/jem/jem.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,21 @@ func (j *JEM) Model(path params.EntityPath) (*mongodoc.Model, error) {
return &m, nil
}

// ModelFromUUID returns the document representing the model with the
// given UUID. It returns an error with a params.ErrNotFound cause if the
// controller was not found.
func (j *JEM) ModelFromUUID(uuid string) (*mongodoc.Model, error) {
var m mongodoc.Model
err := j.DB.Models().Find(bson.D{{"uuid", uuid}}).One(&m)
if err == mgo.ErrNotFound {
return nil, errgo.WithCausef(nil, params.ErrNotFound, "model %q not found", uuid)
}
if err != nil {
return nil, errgo.Notef(err, "cannot get model %q", uuid)
}
return &m, nil
}

// ErrAPIConnection is returned by OpenAPI and OpenAPIFromDocs
// when the API connection cannot be made.
var ErrAPIConnection = errgo.New("cannot connect to API")
Expand Down
26 changes: 26 additions & 0 deletions internal/jem/jem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,32 @@ func (s *jemSuite) TestAddModel(c *gc.C) {
c.Assert(errgo.Cause(err), gc.Equals, params.ErrAlreadyExists)
}

func (s *jemSuite) TestModelFromUUID(c *gc.C) {
uuid := "99999999-9999-9999-9999-999999999999"
path := params.EntityPath{"bob", "x"}
m := &mongodoc.Model{
Id: "ignored",
Path: path,
UUID: uuid,
}
err := s.store.AddModel(m)
c.Assert(err, gc.IsNil)
c.Assert(m, jc.DeepEquals, &mongodoc.Model{
Id: "bob/x",
Path: path,
UUID: uuid,
})

m1, err := s.store.ModelFromUUID(uuid)
c.Assert(err, gc.IsNil)
c.Assert(m1, jc.DeepEquals, m)

m2, err := s.store.ModelFromUUID("no-such-uuid")
c.Assert(err, gc.ErrorMatches, `model "no-such-uuid" not found`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also check the error cause (not found).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

c.Assert(errgo.Cause(err), gc.Equals, params.ErrNotFound)
c.Assert(m2, gc.IsNil)
}

func (s *jemSuite) TestAddTemplate(c *gc.C) {
path := params.EntityPath{"bob", "x"}
tmpl := &mongodoc.Template{
Expand Down
2 changes: 1 addition & 1 deletion internal/jujuapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/CanonicalLtd/jem/internal/jemserver"
)

func NewAPIHandler(jp *jem.Pool, sp jemserver.Params) ([]httprequest.Handler, error) {
func NewAPIHandler(jp *jem.Pool, _ jemserver.Params) ([]httprequest.Handler, error) {
return []httprequest.Handler{
newWebSocketHandler(jp),
}, nil
Expand Down
177 changes: 158 additions & 19 deletions internal/jujuapi/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,51 @@
package jujuapi

import (
"reflect"

"github.com/juju/juju/apiserver/common"
"github.com/juju/juju/apiserver/observer"
jujuparams "github.com/juju/juju/apiserver/params"
"github.com/juju/juju/network"
"github.com/juju/juju/rpc"
"github.com/juju/juju/rpc/jsoncodec"
"github.com/juju/juju/rpc/rpcreflect"
"github.com/juju/loggo"
"golang.org/x/net/websocket"
"gopkg.in/errgo.v1"
"gopkg.in/juju/names.v2"
"gopkg.in/macaroon-bakery.v1/bakery"
"gopkg.in/macaroon-bakery.v1/bakery/checkers"

"github.com/CanonicalLtd/jem/internal/jem"
"github.com/CanonicalLtd/jem/internal/mongodoc"
"github.com/CanonicalLtd/jem/params"
)

// newWSServer creates a new websocket server suitible for handling the api for modelUUID.
var logger = loggo.GetLogger("jem.internal.jujuapi")

// mapError maps JEM errors to errors suitable for use with the juju API.
func mapError(err error) error {
if err == nil {
return nil
}
logger.Debugf("error: %s\n details: %s", err.Error(), errgo.Details(err))
if _, ok := err.(*jujuparams.Error); ok {
return err
}
msg := err.Error()
code := ""
switch errgo.Cause(err) {
case params.ErrNotFound:
code = jujuparams.CodeNotFound
}
return &jujuparams.Error{
Message: msg,
Code: code,
}
}

// newWSServer creates a new WebSocket server suitible for handling the API for modelUUID.
func newWSServer(jem *jem.JEM, modelUUID string) websocket.Server {
hnd := wsHandler{
jem: jem,
Expand All @@ -24,42 +58,147 @@ func newWSServer(jem *jem.JEM, modelUUID string) websocket.Server {
}
}

// wsHandler is a handler for a particular websocket connection.
// wsHandler is a handler for a particular WebSocket connection.
type wsHandler struct {
jem *jem.JEM
modelUUID string
jem *jem.JEM
modelUUID string
conn *rpc.Conn
model *mongodoc.Model
controller *mongodoc.Controller
}

// handle handles the connection.
func (h *wsHandler) handle(wsConn *websocket.Conn) {
codec := jsoncodec.NewWebsocket(wsConn)
conn := rpc.NewConn(codec, observer.None())
h.conn = rpc.NewConn(codec, observer.None())

// TODO(mhilton) serve something useful on this connection.
err := common.UnknownModelError(h.modelUUID)
conn.ServeFinder(&errRoot{err}, serverError)
conn.Start()
var root rpc.MethodFinder
root = adminRoot{admin{h}}
err := h.resolveUUID()
if err != nil {
root = &errRoot{err}
}
h.conn.ServeFinder(root, mapError)
h.conn.Start()
select {
case <-conn.Dead():
case <-h.conn.Dead():
}
conn.Close()
h.conn.Close()
}

func serverError(err error) error {
if err := common.ServerError(err); err != nil {
return err
func (h *wsHandler) resolveUUID() error {
var err error
h.model, err = h.jem.ModelFromUUID(h.modelUUID)
if err != nil {
return errgo.Mask(err, errgo.Is(params.ErrNotFound))
}
h.controller, err = h.jem.Controller(h.model.Controller)
return errgo.Mask(err)
}

type admin struct {
handler *wsHandler
}

func (a admin) Admin(id string) (admin, error) {
if id != "" {
// Safeguard id for possible future use.
return admin{}, common.ErrBadId
}
return a, nil
}

// Login implements the Login method on the Admin facade.
func (a admin) Login(req jujuparams.LoginRequest) (jujuparams.LoginResultV1, error) {
// JAAS only supports macaroon login, ignore all the other fields.
attr, err := a.handler.jem.Bakery.CheckAny(req.Macaroons, nil, checkers.TimeBefore)
if err != nil {
if verr, ok := err.(*bakery.VerificationError); ok {
m, err := a.handler.jem.NewMacaroon()
if err != nil {
return jujuparams.LoginResultV1{}, errgo.Notef(err, "cannot create macaroon")
}
return jujuparams.LoginResultV1{
DischargeRequired: m,
DischargeRequiredReason: verr.Error(),
}, nil
}
return jujuparams.LoginResultV1{}, errgo.Mask(err)
}
a.handler.jem.Auth.Username = attr["username"]
if err := a.handler.jem.CheckCanRead(a.handler.model); err != nil {
return jujuparams.LoginResultV1{}, errgo.Mask(err, errgo.Is(params.ErrUnauthorized))
}

// Login successful
a.handler.jem.Auth = jem.Authorization{attr["username"]}

// If the UUID is for a model send a redirect error.
if a.handler.model.Id != a.handler.controller.Id {
return jujuparams.LoginResultV1{}, &jujuparams.Error{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to understand the code, how do clients know where to redirect?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, should they call RedirectInfo right after the failure response?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, That logic is in juju already.

Code: jujuparams.CodeRedirect,
Message: "redirect required",
}
}

// TODO (mhilton) serve some new methods
return jujuparams.LoginResultV1{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Juju returns more info in case of successful login, like facades and current user info. Is that part of your TODO above?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is yes, especially the facades part.

ModelTag: names.NewModelTag(a.handler.model.UUID).String(),
ControllerTag: names.NewModelTag(a.handler.controller.UUID).String(),
ServerVersion: "2.0.0",
}, nil
}

// RedirectInfo implements the RedirectInfo method on the Admin facade.
func (a admin) RedirectInfo() (jujuparams.RedirectInfoResult, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a docstring here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, and on Login above

if a.handler.jem.Auth.Username == "" {
return jujuparams.RedirectInfoResult{}, params.ErrUnauthorized
}
if err := a.handler.jem.CheckCanRead(a.handler.model); err != nil {
return jujuparams.RedirectInfoResult{}, errgo.Mask(err, errgo.Is(params.ErrUnauthorized))
}
if a.handler.model.Id == a.handler.controller.Id {
return jujuparams.RedirectInfoResult{}, errgo.New("not redirected")
}
nhps, err := network.ParseHostPorts(a.handler.controller.HostPorts...)
if err != nil {
return jujuparams.RedirectInfoResult{}, errgo.Mask(err)
}
hps := jujuparams.FromNetworkHostPorts(nhps)
return jujuparams.RedirectInfoResult{
Servers: [][]jujuparams.HostPort{hps},
CACert: a.handler.controller.CACert,
}, nil
}

// adminRoot is a rpc.MethodFinder that implements the admin interface.
type adminRoot struct {
admin
}

// FindMethod implements rpc.MethodFinder.
func (r adminRoot) FindMethod(rootName string, version int, methodName string) (rpcreflect.MethodCaller, error) {
if rootName != "Admin" {
return nil, &rpcreflect.CallNotImplementedError{
RootMethod: rootName,
Version: version,
}
}
if version < 3 {
return nil, &rpc.RequestError{
Code: jujuparams.CodeNotSupported,
Message: "JAAS does not support login from old clients",
}
}
return nil
return rpcreflect.ValueOf(reflect.ValueOf(r.admin)).FindMethod("Admin", 0, methodName)
}

// errRoot implements the API that a client first sees
// when connecting to the API. It exposes the same API as initialRoot, except
// it returns the requested error when the client makes any request.
// errRoot is a rpc.MethodFinder that always returns an error.
type errRoot struct {
err error
}

// FindMethod conforms to the same API as initialRoot, but we'll always return (nil, err)
// FindMethod implements rpc.MethodFinder, but will always return (nil, err)
func (r *errRoot) FindMethod(rootName string, version int, methodName string) (rpcreflect.MethodCaller, error) {
return nil, r.err
}
Loading