diff --git a/internal/charmstore/store.go b/internal/charmstore/store.go index 6c8d7e25c..6a00f17d1 100644 --- a/internal/charmstore/store.go +++ b/internal/charmstore/store.go @@ -228,6 +228,9 @@ func bundleCharms(data *charm.BundleData) ([]*charm.Reference, error) { return nil, errgo.Mask(err) } urlMap[url.String()] = url + // Also add the corresponding base URL. + base := baseURL(url) + urlMap[base.String()] = base } urls := make([]*charm.Reference, 0, len(urlMap)) for _, url := range urlMap { diff --git a/internal/v4/api.go b/internal/v4/api.go index add1714f9..b96ce5b0b 100644 --- a/internal/v4/api.go +++ b/internal/v4/api.go @@ -52,13 +52,13 @@ func New(store *charmstore.Store) http.Handler { "manifest": h.entityHandler(h.metaManifest, "blobname"), "archive-upload-time": h.entityHandler(h.metaArchiveUploadTime, "uploadtime"), "charm-related": h.entityHandler(h.metaCharmRelated, "charmprovidedinterfaces", "charmrequiredinterfaces"), + "bundles-containing": h.entityHandler(h.metaBundlesContaining), // endpoints not yet implemented - use SingleIncludeHandler for the time being. - "color": router.SingleIncludeHandler(h.metaColor), - "bundles-containing": router.SingleIncludeHandler(h.metaBundlesContaining), - "revision-info": router.SingleIncludeHandler(h.metaRevisionInfo), - "extra-info": router.SingleIncludeHandler(h.metaExtraInfo), - "extra-info/": router.SingleIncludeHandler(h.metaExtraInfoWithKey), + "color": router.SingleIncludeHandler(h.metaColor), + "revision-info": router.SingleIncludeHandler(h.metaRevisionInfo), + "extra-info": router.SingleIncludeHandler(h.metaExtraInfo), + "extra-info/": router.SingleIncludeHandler(h.metaExtraInfoWithKey), }, }, h.resolveURL) return h @@ -160,6 +160,18 @@ func preferredURL(url0, url1 *charm.Reference) bool { return ltsReleases[url0.Series] } +// parseBool returns the boolean value represented by the string. +// It accepts "1" or "0". Any other value returns an error. +func parseBool(value string) (bool, error) { + switch value { + case "0", "": + return false, nil + case "1": + return true, nil + } + return false, errgo.Newf(`unexpected bool value %q (must be "0" or "1")`, value) +} + var errNotImplemented = errgo.Newf("method not implemented") // GET search[?text=text][&autocomplete=1][&filter=value…][&limit=limit][&include=meta] @@ -299,12 +311,6 @@ func (h *handler) metaStats(id *charm.Reference, path string, method string, fla return nil, errNotImplemented } -// GET id/meta/bundles-containing[?include=meta[&include=meta…]] -// http://tinyurl.com/oqc386r -func (h *handler) metaBundlesContaining(id *charm.Reference, path string, method string, flags url.Values) (interface{}, error) { - return nil, errNotImplemented -} - // GET id/meta/revision-info // http://tinyurl.com/q6xos7f func (h *handler) metaRevisionInfo(id *charm.Reference, path string, method string, flags url.Values) (interface{}, error) { diff --git a/internal/v4/api_test.go b/internal/v4/api_test.go index 58ded1512..b8f93cf35 100644 --- a/internal/v4/api_test.go +++ b/internal/v4/api_test.go @@ -57,13 +57,20 @@ func storeURL(path string) string { type metaEndpointExpectedValueGetter func(*charmstore.Store, *charm.Reference) (interface{}, error) type metaEndpoint struct { - name string - exclusive int - bundleOnly bool - get metaEndpointExpectedValueGetter + // name names the meta endpoint. + name string + + // exclusive specifies whether the endpoint is + // valid for charms only (charmOnly), bundles only (bundleOnly) + // or to both (zero). + exclusive int + + // get returns the expected data for the endpoint. + get metaEndpointExpectedValueGetter // checkURL holds one URL to sanity check data against. checkURL string + // assertCheckData holds a function that will be used to check that // the get function returns sane data for checkURL. assertCheckData func(c *gc.C, data interface{}) @@ -147,6 +154,36 @@ var metaEndpoints = []metaEndpoint{{ c.Assert(response.UploadTime, gc.Not(jc.Satisfies), time.Time.IsZero) c.Assert(response.UploadTime.Location(), gc.Equals, time.UTC) }, +}, { + name: "charm-related", + exclusive: charmOnly, + get: func(store *charmstore.Store, url *charm.Reference) (interface{}, error) { + // The charms we use for those tests are not related each other. + // Charm relations are independently tested in relations_test.go. + if url.Series == "bundle" { + return nil, nil + } + return ¶ms.RelatedResponse{}, nil + }, + checkURL: "cs:precise/wordpress-23", + assertCheckData: func(c *gc.C, data interface{}) { + c.Assert(data, gc.FitsTypeOf, (*params.RelatedResponse)(nil)) + }, +}, { + name: "bundles-containing", + exclusive: charmOnly, + get: func(store *charmstore.Store, url *charm.Reference) (interface{}, error) { + // The charms we use for those tests are not included in any bundle. + // Charm/bundle relations are tested in relations_test.go. + if url.Series == "bundle" { + return nil, nil + } + return []*params.MetaAnyResponse{}, nil + }, + checkURL: "cs:precise/wordpress-23", + assertCheckData: func(c *gc.C, data interface{}) { + c.Assert(data, gc.FitsTypeOf, []*params.MetaAnyResponse(nil)) + }, }} // TestEndpointGet tries to ensure that the endpoint diff --git a/internal/v4/relations.go b/internal/v4/relations.go index 5696b9ee3..2d68084de 100644 --- a/internal/v4/relations.go +++ b/internal/v4/relations.go @@ -17,6 +17,10 @@ import ( // GET id/meta/charm-related[?include=meta[&include=meta…]] // http://tinyurl.com/q7vdmzl func (h *handler) metaCharmRelated(entity *mongodoc.Entity, id *charm.Reference, path, method string, flags url.Values) (interface{}, error) { + if id.Series == "bundle" { + return nil, nil + } + // If the charm does not define any relation we can just return without // hitting the db. if len(entity.CharmProvidedInterfaces)+len(entity.CharmRequiredInterfaces) == 0 { @@ -132,3 +136,80 @@ func (h *handler) getRelatedIfaceResponses( } return responses, nil } + +// GET id/meta/bundles-containing[?include=meta[&include=meta…]][&any-series=1][&any-revision=1] +// http://tinyurl.com/oqc386r +func (h *handler) metaBundlesContaining(entity *mongodoc.Entity, id *charm.Reference, path, method string, flags url.Values) (interface{}, error) { + if id.Series == "bundle" { + return nil, nil + } + + // Validate the URL query values. + anySeries, err := parseBool(flags.Get("any-series")) + if err != nil { + return nil, badRequestf(err, "invalid value for any-series") + } + anyRevision, err := parseBool(flags.Get("any-revision")) + if err != nil { + return nil, badRequestf(err, "invalid value for any-revision") + } + + // Mutate the reference so that it represents a base URL if required. + searchId := *id + if anySeries || anyRevision { + searchId.Revision = -1 + searchId.Series = "" + } + + // Retrieve the bundles containing the resulting charm id. + var entities []mongodoc.Entity + if err := h.store.DB.Entities(). + Find(bson.D{{"bundlecharms", &searchId}}). + Select(bson.D{{"_id", 1}, {"bundlecharms", 1}}). + All(&entities); err != nil { + return nil, errgo.Notef(err, "cannot retrieve the related bundles") + } + + // Further filter the entities if required. + if anySeries != anyRevision { + predicate := func(e *mongodoc.Entity) bool { + for _, charmId := range e.BundleCharms { + if charmId.Name == id.Name && + charmId.User == id.User && + (anySeries || charmId.Series == id.Series) && + (anyRevision || charmId.Revision == id.Revision) { + return true + } + } + return false + } + entities = filterEntities(entities, predicate) + } + + // Prepare and return the response. + response := make([]*params.MetaAnyResponse, 0, len(entities)) + includes := flags["include"] + for _, e := range entities { + meta, err := h.GetMetadata(e.URL, includes) + if err != nil { + return nil, errgo.Notef(err, "cannot retrieve bundle metadata") + } + response = append(response, ¶ms.MetaAnyResponse{ + Id: e.URL, + Meta: meta, + }) + } + return response, nil +} + +// filterEntities returns a slice containing all the entities for which the +// given predicate returns true. +func filterEntities(entities []mongodoc.Entity, predicate func(*mongodoc.Entity) bool) []mongodoc.Entity { + results := make([]mongodoc.Entity, 0, len(entities)) + for _, entity := range entities { + if predicate(&entity) { + results = append(results, entity) + } + } + return results +} diff --git a/internal/v4/relations_test.go b/internal/v4/relations_test.go index 1d3e891e7..d34c6cae8 100644 --- a/internal/v4/relations_test.go +++ b/internal/v4/relations_test.go @@ -4,16 +4,27 @@ package v4_test import ( + "fmt" "net/http" "gopkg.in/juju/charm.v3" + "gopkg.in/mgo.v2/bson" gc "launchpad.net/gocheck" + "github.com/juju/charmstore/internal/blobstore" "github.com/juju/charmstore/internal/charmstore" "github.com/juju/charmstore/internal/storetesting" "github.com/juju/charmstore/params" ) +// Define fake blob attributes to be used in tests. +var fakeBlobSize, fakeBlobHash = func() (int64, string) { + b := []byte("fake content") + h := blobstore.NewHash() + h.Write(b) + return int64(len(b)), fmt.Sprintf("%x", h.Sum(nil)) +}() + type RelationsSuite struct { storetesting.IsolatedMgoSuite srv http.Handler @@ -237,7 +248,7 @@ var metaCharmRelatedTests = []struct { "mount": []params.MetaAnyResponse{{ Id: mustParseReference("utopic/wordpress-0"), Meta: map[string]interface{}{ - "archive-size": params.ArchiveSizeResponse{Size: 42}, + "archive-size": params.ArchiveSizeResponse{Size: fakeBlobSize}, "charm-metadata": &charm.Meta{ Provides: map[string]charm.Relation{ "website": { @@ -266,13 +277,12 @@ var metaCharmRelatedTests = []struct { }} func (s *RelationsSuite) addCharms(c *gc.C, charms map[string]charm.Charm) { - blobSize := int64(42) for id, ch := range charms { url := mustParseReference(id) // The blob related info are not used in these tests. // The related charms are retrieved from the entities collection, // without accessing the blob store. - err := s.store.AddCharm(url, ch, "blobName", "blobHash", blobSize) + err := s.store.AddCharm(url, ch, "blobName", fakeBlobHash, fakeBlobSize) c.Assert(err, gc.IsNil) } } @@ -339,3 +349,250 @@ func (ch *relationTestingCharm) Revision() int { // relevant. return 0 } + +// metaBundlesContainingBundles defines a bunch of bundles to be used in +// the bundles-containing tests. +var metaBundlesContainingBundles = map[string]charm.Bundle{ + "bundle/wordpress-simple-0": &relationTestingBundle{ + urls: []string{ + "cs:utopic/wordpress-42", + "cs:utopic/mysql-0", + }, + }, + "bundle/wordpress-complex-1": &relationTestingBundle{ + urls: []string{ + "cs:utopic/wordpress-42", + "cs:utopic/wordpress-47", + "cs:trusty/mysql-0", + "cs:trusty/mysql-1", + "cs:trusty/memcached-2", + }, + }, + "bundle/django-generic-42": &relationTestingBundle{ + urls: []string{ + "django", + "django", + "mysql-1", + "trusty/memcached", + }, + }, + "bundle/useless-0": &relationTestingBundle{ + urls: []string{"cs:utopic/wordpress-42"}, + }, + "bundle/mediawiki-47": &relationTestingBundle{ + urls: []string{ + "precise/mediawiki-0", + "mysql", + }, + }, +} + +var metaBundlesContainingTests = []struct { + // Description of the test. + about string + // The id of the charm for which related bundles are returned. + id string + // The querystring to append to the resulting charmstore URL. + querystring string + // The expected status code of the response. + expectCode int + // The expected response body. + expectBody interface{} +}{{ + about: "specific charm present in several bundles", + id: "utopic/wordpress-42", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-simple-0"), + }, { + Id: mustParseReference("bundle/wordpress-complex-1"), + }, { + Id: mustParseReference("bundle/useless-0"), + }}, +}, { + about: "specific charm present in one bundle", + id: "trusty/memcached-2", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-complex-1"), + }}, +}, { + about: "specific charm not present in any bundle", + id: "trusty/django-42", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{}, +}, { + about: "specific charm with includes", + id: "trusty/mysql-1", + querystring: "?include=archive-size&include=bundle-metadata", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-complex-1"), + Meta: map[string]interface{}{ + "archive-size": params.ArchiveSizeResponse{Size: fakeBlobSize}, + "bundle-metadata": metaBundlesContainingBundles["bundle/wordpress-complex-1"].Data(), + }, + }}, +}, { + about: "partial charm id", + id: "mysql", // The test will add cs:utopic/mysql-0. + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-simple-0"), + }}, +}, { + about: "any series set to true", + id: "trusty/mysql-0", + querystring: "?any-series=1", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-simple-0"), + }, { + Id: mustParseReference("bundle/wordpress-complex-1"), + }}, +}, { + about: "invalid any series", + id: "utopic/mysql-0", + querystring: "?any-series=true", + expectCode: http.StatusBadRequest, + expectBody: params.Error{ + Code: params.ErrBadRequest, + Message: `invalid value for any-series: unexpected bool value "true" (must be "0" or "1")`, + }, +}, { + about: "any revision set to true", + id: "trusty/memcached-99", + querystring: "?any-revision=1", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-complex-1"), + }, { + Id: mustParseReference("bundle/django-generic-42"), + }}, +}, { + about: "invalid any revision", + id: "trusty/memcached-99", + querystring: "?any-revision=why-not", + expectCode: http.StatusBadRequest, + expectBody: params.Error{ + Code: params.ErrBadRequest, + Message: `invalid value for any-revision: unexpected bool value "why-not" (must be "0" or "1")`, + }, +}, { + about: "any series and revision", + id: "saucy/mysql-99", + querystring: "?any-series=1&any-revision=1", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-simple-0"), + }, { + Id: mustParseReference("bundle/wordpress-complex-1"), + }, { + Id: mustParseReference("bundle/django-generic-42"), + }, { + Id: mustParseReference("bundle/mediawiki-47"), + }}, +}, { + about: "any series and revision with includes", + id: "saucy/wordpress-99", + querystring: "?any-series=1&any-revision=1&include=archive-size&include=bundle-metadata", + expectCode: http.StatusOK, + expectBody: []*params.MetaAnyResponse{{ + Id: mustParseReference("bundle/wordpress-simple-0"), + Meta: map[string]interface{}{ + "archive-size": params.ArchiveSizeResponse{Size: fakeBlobSize}, + "bundle-metadata": metaBundlesContainingBundles["bundle/wordpress-simple-0"].Data(), + }, + }, { + Id: mustParseReference("bundle/wordpress-complex-1"), + Meta: map[string]interface{}{ + "archive-size": params.ArchiveSizeResponse{Size: fakeBlobSize}, + "bundle-metadata": metaBundlesContainingBundles["bundle/wordpress-complex-1"].Data(), + }, + }, { + Id: mustParseReference("bundle/useless-0"), + Meta: map[string]interface{}{ + "archive-size": params.ArchiveSizeResponse{Size: fakeBlobSize}, + "bundle-metadata": metaBundlesContainingBundles["bundle/useless-0"].Data(), + }, + }}, +}, { + about: "include-error", + id: "utopic/wordpress-42", + querystring: "?include=no-such", + expectCode: http.StatusInternalServerError, + expectBody: params.Error{ + Message: `cannot retrieve bundle metadata: unrecognized metadata name "no-such"`, + }, +}} + +func (s *RelationsSuite) TestMetaBundlesContaining(c *gc.C) { + // Add the bundles used for testing to the database. + for id, b := range metaBundlesContainingBundles { + url := mustParseReference(id) + // The blob related info are not used in these tests. + // The charm-bundle relations are retrieved from the entities + // collection, without accessing the blob store. + err := s.store.AddBundle(url, b, "blobName", fakeBlobHash, fakeBlobSize) + c.Assert(err, gc.IsNil) + } + + for i, test := range metaBundlesContainingTests { + c.Logf("test %d: %s", i, test.about) + + // Expand the URL if required before adding the charm to the database, + // so that at least one matching charm can be resolved. + url := mustParseReference(test.id) + if url.Series == "" { + url.Series = "utopic" + } + if url.Revision == -1 { + url.Revision = 0 + } + + // Add the charm we need bundle info on to the database. + err := s.store.AddCharm(url, &relationTestingCharm{}, "blobName", fakeBlobHash, fakeBlobSize) + c.Assert(err, gc.IsNil) + + // Perform the request and ensure the response is what we expect. + storeURL := storeURL(test.id + "/meta/bundles-containing" + test.querystring) + storetesting.AssertJSONCall(c, storetesting.JSONCallParams{ + Handler: s.srv, + URL: storeURL, + ExpectCode: test.expectCode, + ExpectBody: test.expectBody, + }) + + // Clean up the charm entity in the store. + err = s.store.DB.Entities().Remove(bson.D{{"_id", url}}) + c.Assert(err, gc.IsNil) + } +} + +// relationTestingBundle implements charm.Bundle, and it is used for testing +// charm to bundle relations (for instance for the bundles-containing call). +type relationTestingBundle struct { + // urls is a list of charm references to be included in the bundle. + // For each URL, a corresponding service is automatically created. + urls []string +} + +func (b *relationTestingBundle) Data() *charm.BundleData { + services := make(map[string]*charm.ServiceSpec, len(b.urls)) + for i, url := range b.urls { + service := &charm.ServiceSpec{ + Charm: url, + NumUnits: 1, + } + services[fmt.Sprintf("service-%d", i)] = service + } + return &charm.BundleData{ + Services: services, + } +} + +func (b *relationTestingBundle) ReadMe() string { + // For the purposes of this implementation, the charm readme is not + // relevant. + return "" +}