diff --git a/client.go b/client.go index f736c19..3b346c4 100644 --- a/client.go +++ b/client.go @@ -274,6 +274,21 @@ func AddAPIVersionToURL(BaseURL, apiVersion string) string { return fmt.Sprintf("%sapi/%s/", baseurl, apiVersion) } +var apiVersionPattern = regexp.MustCompile(`^(?P.*/)api/(?P\d+\.\d+)/?$`) + +//splitVersionedURL splits a versioned API URL (like +//http://maas.server/MAAS/api/2.0/) into a base URL +//(http://maas.server/MAAS/) and API version (2.0). If the URL doesn't +//include a version component the bool return value will be false. +func splitVersionedURL(url string) (string, string, bool) { + if !apiVersionPattern.MatchString(url) { + return url, "", false + } + version := apiVersionPattern.ReplaceAllString(url, "$version") + baseURL := apiVersionPattern.ReplaceAllString(url, "$base") + return baseURL, version, true +} + // NewAnonymousClient creates a client that issues anonymous requests. // BaseURL should refer to the root of the MAAS server path, e.g. // http://my.maas.server.example.com/MAAS/ diff --git a/client_test.go b/client_test.go index 6b81da3..49a3805 100644 --- a/client_test.go +++ b/client_test.go @@ -311,7 +311,20 @@ func (suite *ClientSuite) TestNewAuthenticatedClientFailsIfInvalidKey(c *gc.C) { } func (suite *ClientSuite) TestAddAPIVersionToURL(c *gc.C) { - apiurl := AddAPIVersionToURL("http://example.com/MAAS", "1.0") - expectedURL := "http://example.com/MAAS/api/1.0/" - c.Assert(expectedURL, jc.DeepEquals, apiurl) + addVersion := AddAPIVersionToURL + c.Assert(addVersion("http://example.com/MAAS", "1.0"), gc.Equals, "http://example.com/MAAS/api/1.0/") + c.Assert(addVersion("http://example.com/MAAS/", "2.0"), gc.Equals, "http://example.com/MAAS/api/2.0/") +} + +func (suite *ClientSuite) TestSplitVersionedURL(c *gc.C) { + check := func(url, expectedBase, expectedVersion string, expectedResult bool) { + base, version, ok := splitVersionedURL(url) + c.Check(ok, gc.Equals, expectedResult) + c.Check(base, gc.Equals, expectedBase) + c.Check(version, gc.Equals, expectedVersion) + } + check("http://maas.server/MAAS", "http://maas.server/MAAS", "", false) + check("http://maas.server/MAAS/api/3.0", "http://maas.server/MAAS/", "3.0", true) + check("http://maas.server/MAAS/api/3.0/", "http://maas.server/MAAS/", "3.0", true) + check("http://maas.server/MAAS/api/maas", "http://maas.server/MAAS/api/maas", "", false) } diff --git a/controller.go b/controller.go index 5a9b846..dceb568 100644 --- a/controller.go +++ b/controller.go @@ -44,47 +44,85 @@ type ControllerArgs struct { APIKey string } -// NewController creates an authenticated client to the MAAS API, and checks -// the capabilities of the server. +// NewController creates an authenticated client to the MAAS API, and +// checks the capabilities of the server. If the BaseURL specified +// includes the API version, that version of the API will be used, +// otherwise the controller will use the highest supported version +// available. // // If the APIKey is not valid, a NotValid error is returned. // If the credentials are incorrect, a PermissionError is returned. func NewController(args ControllerArgs) (Controller, error) { + base, apiVersion, includesVersion := splitVersionedURL(args.BaseURL) + if includesVersion { + if !supportedVersion(apiVersion) { + return nil, NewUnsupportedVersionError("version %s", apiVersion) + } + return newControllerWithVersion(base, apiVersion, args.APIKey) + } + return newControllerUnknownVersion(args) +} + +func supportedVersion(value string) bool { + for _, version := range supportedAPIVersions { + if value == version { + return true + } + } + return false +} + +func newControllerWithVersion(baseURL, apiVersion, apiKey string) (Controller, error) { + major, minor, err := version.ParseMajorMinor(apiVersion) + // We should not get an error here. See the test. + if err != nil { + return nil, errors.Errorf("bad version defined in supported versions: %q", apiVersion) + } + client, err := NewAuthenticatedClient(AddAPIVersionToURL(baseURL, apiVersion), apiKey) + if err != nil { + // If the credentials aren't valid, return now. + if errors.IsNotValid(err) { + return nil, errors.Trace(err) + } + // Any other error attempting to create the authenticated client + // is an unexpected error and return now. + return nil, NewUnexpectedError(err) + } + controllerVersion := version.Number{ + Major: major, + Minor: minor, + } + controller := &controller{client: client, apiVersion: controllerVersion} + // The controllerVersion returned from the function will include any patch version. + controller.capabilities, err = controller.readAPIVersionInfo() + if err != nil { + logger.Debugf("read version failed: %#v", err) + return nil, NewBadVersionInfoError(err) + } + + if err := controller.checkCreds(); err != nil { + return nil, errors.Trace(err) + } + return controller, nil +} + +func newControllerUnknownVersion(args ControllerArgs) (Controller, error) { // For now we don't need to test multiple versions. It is expected that at // some time in the future, we will try the most up to date version and then // work our way backwards. for _, apiVersion := range supportedAPIVersions { - major, minor, err := version.ParseMajorMinor(apiVersion) - // We should not get an error here. See the test. - if err != nil { - return nil, errors.Errorf("bad version defined in supported versions: %q", apiVersion) - } - client, err := NewAuthenticatedClient(AddAPIVersionToURL(args.BaseURL, apiVersion), args.APIKey) - if err != nil { - // If the credentials aren't valid, return now. - if errors.IsNotValid(err) { - return nil, errors.Trace(err) - } - // Any other error attempting to create the authenticated client - // is an unexpected error and return now. - return nil, NewUnexpectedError(err) - } - controllerVersion := version.Number{ - Major: major, - Minor: minor, - } - controller := &controller{client: client, apiVersion: controllerVersion} - // The controllerVersion returned from the function will include any patch version. - controller.capabilities, err = controller.readAPIVersionInfo() - if err != nil { - logger.Debugf("read version failed: %#v", err) + controller, err := newControllerWithVersion(args.BaseURL, apiVersion, args.APIKey) + switch { + case err == nil: + return controller, nil + case IsBadVersionInfoError(err): + // TODO(babbageclunk): this is bad - it treats transient + // network errors the same as version mismatches. See + // lp:1667095 continue - } - - if err := controller.checkCreds(); err != nil { + default: return nil, errors.Trace(err) } - return controller, nil } return nil, NewUnsupportedVersionError("controller at %s does not support any of %s", args.BaseURL, supportedAPIVersions) diff --git a/controller_test.go b/controller_test.go index f834e3d..3928e28 100644 --- a/controller_test.go +++ b/controller_test.go @@ -132,6 +132,36 @@ func (s *controllerSuite) TestNewControllerUnexpected(c *gc.C) { c.Assert(err, jc.Satisfies, IsUnexpectedError) } +func (s *controllerSuite) TestNewControllerKnownVersion(c *gc.C) { + // Using a server URL including the version should work. + officialController, err := NewController(ControllerArgs{ + BaseURL: s.server.URL + "/api/2.0/", + APIKey: "fake:as:key", + }) + c.Assert(err, jc.ErrorIsNil) + rawController, ok := officialController.(*controller) + c.Assert(ok, jc.IsTrue) + c.Assert(rawController.apiVersion, gc.Equals, version.Number{ + Major: 2, + Minor: 0, + }) +} + +func (s *controllerSuite) TestNewControllerUnsupportedVersionSpecified(c *gc.C) { + // Ensure the server would actually respond to the version if it + // was asked. + s.server.AddGetResponse("/api/3.0/users/?op=whoami", http.StatusOK, `"captain awesome"`) + s.server.AddGetResponse("/api/3.0/version/", http.StatusOK, versionResponse) + // Using a server URL including a version that isn't in the known + // set should be denied. + controller, err := NewController(ControllerArgs{ + BaseURL: s.server.URL + "/api/3.0/", + APIKey: "fake:as:key", + }) + c.Assert(controller, gc.IsNil) + c.Assert(err, jc.Satisfies, IsUnsupportedVersionError) +} + func (s *controllerSuite) TestBootResources(c *gc.C) { controller := s.getController(c) resources, err := controller.BootResources() diff --git a/errors.go b/errors.go index 8931d56..ff83004 100644 --- a/errors.go +++ b/errors.go @@ -65,6 +65,27 @@ func IsUnsupportedVersionError(err error) bool { return ok } +// BadVersionInfoError is more specific than UnsupportedVersionError - +// it means that we couldn't read the version info endpoint, so the +// API version is probably wrong. Used in version selection when +// creating a controller. +type BadVersionInfoError struct { + errors.Err +} + +// NewBadVersionInfoError constructs a new BadVersionInfoError and sets the location. +func NewBadVersionInfoError(err error) error { + uerr := &BadVersionInfoError{Err: errors.NewErr("bad version info: %v", err)} + uerr.SetLocation(1) + return errors.Wrap(err, uerr) +} + +// IsBadVersionInfoError returns true if err is an BadVersionInfoError. +func IsBadVersionInfoError(err error) bool { + _, ok := errors.Cause(err).(*BadVersionInfoError) + return ok +} + // DeserializationError types are returned when the returned JSON data from // the controller doesn't match the code's expectations. type DeserializationError struct { diff --git a/errors_test.go b/errors_test.go index a625577..1174c46 100644 --- a/errors_test.go +++ b/errors_test.go @@ -29,6 +29,14 @@ func (*errorTypesSuite) TestUnexpectedError(c *gc.C) { c.Assert(err.Error(), gc.Equals, "unexpected: wat") } +func (*errorTypesSuite) TestNewBadVersionInfoError(c *gc.C) { + err := errors.New("wat") + err = NewBadVersionInfoError(err) + c.Assert(err, gc.NotNil) + c.Assert(err, jc.Satisfies, IsBadVersionInfoError) + c.Assert(err.Error(), gc.Equals, "bad version info: wat") +} + func (*errorTypesSuite) TestUnsupportedVersionError(c *gc.C) { err := NewUnsupportedVersionError("foo %d", 42) c.Assert(err, gc.NotNil)