diff --git a/internal/monocular/client.go b/internal/monocular/client.go new file mode 100644 index 000000000..88a2564b9 --- /dev/null +++ b/internal/monocular/client.go @@ -0,0 +1,68 @@ +/* +Copyright The Helm Authors. + +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 monocular + +import ( + "errors" + "net/url" +) + +// ErrHostnameNotProvided indicates the url is missing a hostname +var ErrHostnameNotProvided = errors.New("no hostname provided") + +// Client represents a client capable of communicating with the Monocular API. +type Client struct { + + // The base URL for requests + BaseURL string + + // The internal logger to use + Log func(string, ...interface{}) +} + +// New creates a new client +func New(u string) (*Client, error) { + + // Validate we have a URL + if err := validate(u); err != nil { + return nil, err + } + + return &Client{ + BaseURL: u, + Log: nopLogger, + }, nil +} + +var nopLogger = func(_ string, _ ...interface{}) {} + +// Validate if the base URL for monocular is valid. +func validate(u string) error { + + // Check if it is parsable + p, err := url.Parse(u) + if err != nil { + return err + } + + // Check that a host is attached + if p.Hostname() == "" { + return ErrHostnameNotProvided + } + + return nil +} diff --git a/internal/monocular/client_test.go b/internal/monocular/client_test.go new file mode 100644 index 000000000..abf914ef5 --- /dev/null +++ b/internal/monocular/client_test.go @@ -0,0 +1,31 @@ +/* +Copyright The Helm Authors. + +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 monocular + +import ( + "testing" +) + +func TestNew(t *testing.T) { + c, err := New("https://hub.helm.sh") + if err != nil { + t.Errorf("error creating client: %s", err) + } + if c.BaseURL != "https://hub.helm.sh" { + t.Errorf("incorrect BaseURL. Expected \"https://hub.helm.sh\" but got %q", c.BaseURL) + } +} diff --git a/internal/monocular/doc.go b/internal/monocular/doc.go new file mode 100644 index 000000000..485cfdd45 --- /dev/null +++ b/internal/monocular/doc.go @@ -0,0 +1,21 @@ +/* +Copyright The Helm Authors. + +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 monocular contains the logic for interacting with monocular instances +// like the Helm Hub. +// +// This is a library for interacting with monocular +package monocular diff --git a/internal/monocular/search.go b/internal/monocular/search.go new file mode 100644 index 000000000..feadf93df --- /dev/null +++ b/internal/monocular/search.go @@ -0,0 +1,139 @@ +/* +Copyright The Helm Authors. + +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 monocular + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "time" + + "helm.sh/helm/internal/version" + "helm.sh/helm/pkg/chart" +) + +// SearchPath is the url path to the search API in monocular. +const SearchPath = "api/chartsvc/v1/charts/search" + +// The structs below represent the structure of the response from the monocular +// search API. The structs were not imported from monocular because monocular +// imports from Helm v2 (avoiding circular version dependency) and the mappings +// are slightly different (monocular search results do not directly reflect +// the struct definitions). + +// SearchResult represents an individual chart result +type SearchResult struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes Chart `json:"attributes"` + Links Links `json:"links"` + Relationships Relationships `json:"relationships"` +} + +// Chart is the attributes for the chart +type Chart struct { + Name string `json:"name"` + Repo Repo `json:"repo"` + Description string `json:"description"` + Home string `json:"home"` + Keywords []string `json:"keywords"` + Maintainers []chart.Maintainer `json:"maintainers"` + Sources []string `json:"sources"` + Icon string `json:"icon"` +} + +// Repo contains the name in monocular the the url for the repository +type Repo struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// Links provides a set of links relative to the chartsvc base +type Links struct { + Self string `json:"self"` +} + +// Relationships provides information on the latest version of the chart +type Relationships struct { + LatestChartVersion LatestChartVersion `json:"latestChartVersion"` +} + +// LatestChartVersion provides the details on the latest version of the chart +type LatestChartVersion struct { + Data ChartVersion `json:"data"` + Links Links `json:"links"` +} + +// ChartVersion provides the specific data on the chart version +type ChartVersion struct { + Version string `json:"version"` + AppVersion string `json:"app_version"` + Created time.Time `json:"created"` + Digest string `json:"digest"` + Urls []string `json:"urls"` + Readme string `json:"readme"` + Values string `json:"values"` +} + +// Search performs a search against the monocular search API +func (c *Client) Search(term string) ([]SearchResult, error) { + + // Create the URL to the search endpoint + // Note, this is currently an internal API for the Hub. This should be + // formatted without showing how monocular operates. + p, err := url.Parse(c.BaseURL) + if err != nil { + return nil, err + } + + // Set the path to the monocular API endpoint for search + p.Path = path.Join(p.Path, SearchPath) + + p.RawQuery = "q=" + url.QueryEscape(term) + + // Create request + req, err := http.NewRequest("GET", p.String(), nil) + if err != nil { + return nil, err + } + + // Set the user agent so that monocular can identify where the request + // is coming from + req.Header.Set("User-Agent", version.GetUserAgent()) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("failed to fetch %s : %s", p.String(), res.Status) + } + + result := &searchResponse{} + + json.NewDecoder(res.Body).Decode(result) + + return result.Data, nil +} + +type searchResponse struct { + Data []SearchResult `json:"data"` +} diff --git a/internal/monocular/search_test.go b/internal/monocular/search_test.go new file mode 100644 index 000000000..3e296f240 --- /dev/null +++ b/internal/monocular/search_test.go @@ -0,0 +1,49 @@ +/* +Copyright The Helm Authors. + +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 monocular + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// A search response for phpmyadmin containing 2 results +var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://kubernetes-charts.storage.googleapis.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://kubernetes-charts.storage.googleapis.com/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` + +func TestSearch(t *testing.T) { + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, searchResult) + })) + defer ts.Close() + + c, err := New(ts.URL) + if err != nil { + t.Errorf("unable to create monocular client: %s", err) + } + + results, err := c.Search("phpmyadmin") + if err != nil { + t.Errorf("unable to search monocular: %s", err) + } + + if len(results) != 2 { + t.Error("Did not receive the expected number of results") + } +} diff --git a/internal/version/version.go b/internal/version/version.go index d7caa11f5..394476039 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -19,6 +19,7 @@ package version // import "helm.sh/helm/internal/version" import ( "flag" "runtime" + "strings" ) var ( @@ -59,6 +60,11 @@ func GetVersion() string { return version + "+" + metadata } +// GetUserAgent returns a user agent for user with an HTTP client +func GetUserAgent() string { + return "Helm/" + strings.TrimPrefix(GetVersion(), "v") +} + // Get returns build info func Get() BuildInfo { v := BuildInfo{ diff --git a/pkg/getter/httpgetter.go b/pkg/getter/httpgetter.go index 4081c1278..5e99951c3 100644 --- a/pkg/getter/httpgetter.go +++ b/pkg/getter/httpgetter.go @@ -19,7 +19,6 @@ import ( "bytes" "io" "net/http" - "strings" "github.com/pkg/errors" @@ -49,7 +48,7 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { return buf, err } - req.Header.Set("User-Agent", "Helm/"+strings.TrimPrefix(version.GetVersion(), "v")) + req.Header.Set("User-Agent", version.GetUserAgent()) if g.opts.userAgent != "" { req.Header.Set("User-Agent", g.opts.userAgent) }