diff --git a/chart/monocular/templates/ingress.yaml b/chart/monocular/templates/ingress.yaml index 4075fc115..4469aad3e 100644 --- a/chart/monocular/templates/ingress.yaml +++ b/chart/monocular/templates/ingress.yaml @@ -21,6 +21,10 @@ spec: serviceName: {{ template "fullname" $ }}-ui servicePort: {{ $.Values.ui.service.externalPort }} path: /?(.*) + - backend: + serviceName: {{ template "fullname" $ }}-chartsvc + servicePort: {{ $.Values.chartsvc.service.port }} + path: /api/?(.*) - backend: serviceName: {{ template "fullname" $ }}-chartsvc servicePort: {{ $.Values.chartsvc.service.port }} diff --git a/chart/monocular/templates/ui-vhost.yaml b/chart/monocular/templates/ui-vhost.yaml index b1d720c2f..ebce0e933 100644 --- a/chart/monocular/templates/ui-vhost.yaml +++ b/chart/monocular/templates/ui-vhost.yaml @@ -13,6 +13,10 @@ data: server {{ template "fullname" . }}-prerender; } + upstream chartsvc { + server {{ template "fullname" . }}-chartsvc:{{ .Values.chartsvc.service.port }}; + } + server { listen {{ .Values.ui.service.internalPort }}; @@ -21,33 +25,46 @@ data: gzip_static on; location / { - try_files $uri @prerender; + try_files $uri @findredirect; } - location @prerender { - set $prerender 0; + location @findredirect { + set $findredirect 0; + + # Intercept some errors, like redirects, and follow them. + proxy_intercept_errors on; + # Look for the Helm user agent. If a Helm client wants the URL we redirect + # to the file being requested. + if ($http_user_agent ~* "helm") { + set $findredirect 2; + } + + # Detect bots that want HTML. We send this to a prerender service if ($http_user_agent ~* "baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest|slackbot|vkShare|W3C_Validator") { - set $prerender 1; + set $findredirect 1; } if ($args ~ "_escaped_fragment_") { - set $prerender 1; + set $findredirect 1; } if ($http_user_agent ~ "Prerender") { - set $prerender 0; + set $findredirect 0; } if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") { - set $prerender 0; + set $findredirect 0; } - if ($prerender = 1) { + if ($findredirect = 2) { + proxy_pass http://chartsvc/v1/redirect$request_uri; + } + if ($findredirect = 1) { rewrite .* /https://$host$request_uri? break; proxy_pass http://target_service; } - if ($prerender = 0) { + if ($findredirect = 0) { rewrite .* /index.html break; } } diff --git a/cmd/chartsvc/handler.go b/cmd/chartsvc/handler.go index d4eeb44f9..49454420a 100644 --- a/cmd/chartsvc/handler.go +++ b/cmd/chartsvc/handler.go @@ -21,6 +21,7 @@ import ( "math" "net/http" "strconv" + "strings" "github.com/globalsign/mgo/bson" "github.com/gorilla/mux" @@ -459,3 +460,74 @@ func newChartVersionListResponse(c *models.Chart) apiListResponse { return cvl } + +func redirectToChartVersionPackage(w http.ResponseWriter, req *http.Request) { + // The URL can be in one of two forms: + // - /v1/redirect/charts/stable/aerospike + // - /v1/redirect/charts/stable/aerospike/v1.2.3 + // And either of these can optionally have a trailing / + + // Make sure the path is valid + ct := strings.TrimPrefix(req.URL.Path, "/v1/redirect/charts/") + + // check if URL for provenance + prov := strings.HasSuffix(ct, ".prov") + ct = strings.TrimSuffix(ct, ".prov") + + ct = strings.TrimSuffix(ct, "/") // Removing the optional / on the end + parts := strings.Split(ct, "/") + + // Not enough parts passed in to the path + if len(parts) < 2 || len(parts) > 3 { + response.NewErrorResponse(http.StatusNotFound, "could not find chart").Write(w) + return + } + + // Decide if latest or a version + var version string + if len(parts) == 3 { + version = parts[2] + } + + // Look it up. This will be different if there is a version or we are getting + // the latest + db, closer := dbSession.DB() + defer closer() + var chart models.Chart + chartID := fmt.Sprintf("%s/%s", parts[0], parts[1]) + + if version == "" { + if err := db.C(chartCollection).FindId(chartID).One(&chart); err != nil { + log.WithError(err).Errorf("could not find chart with id %s", chartID) + response.NewErrorResponse(http.StatusNotFound, "could not find chart").Write(w) + return + } + } else { + if err := db.C(chartCollection).Find(bson.M{ + "_id": chartID, + "chartversions": bson.M{"$elemMatch": bson.M{"version": version}}, + }).Select(bson.M{ + "name": 1, "repo": 1, "description": 1, "home": 1, "keywords": 1, "maintainers": 1, "sources": 1, + "chartversions.$": 1, + }).One(&chart); err != nil { + log.WithError(err).Errorf("could not find chart with id %s", chartID) + response.NewErrorResponse(http.StatusNotFound, "could not find chart version").Write(w) + return + } + } + + // Respond with proper redirect for tarball and prov + if len(chart.ChartVersions) > 0 { + cv := chart.ChartVersions[0] + if len(cv.URLs) > 0 { + if prov { + http.Redirect(w, req, cv.URLs[0]+".prov", http.StatusTemporaryRedirect) + } else { + http.Redirect(w, req, cv.URLs[0], http.StatusTemporaryRedirect) + } + return + } + } + + response.NewErrorResponse(http.StatusNotFound, "could not find chart version").Write(w) +} diff --git a/cmd/chartsvc/handler_test.go b/cmd/chartsvc/handler_test.go index ffa578774..a2986c39c 100644 --- a/cmd/chartsvc/handler_test.go +++ b/cmd/chartsvc/handler_test.go @@ -907,3 +907,89 @@ func Test_findLatestChart(t *testing.T) { assert.Equal(t, len(data), 2, "it should return both charts") }) } + +func Test_redirectToChartVersionPackage(t *testing.T) { + tests := []struct { + name string + err error + chart models.Chart + wantCode int + location string + }{ + { + "chart does not exist", + errors.New("return an error when checking if chart exists"), + models.Chart{ID: "my-repo/my-chart"}, + http.StatusNotFound, + "", + }, + { + "chart exists", + nil, + models.Chart{ID: "my-repo/my-chart", ChartVersions: []models.ChartVersion{{Version: "0.1.0", URLs: []string{"https://example.com/my-chart-0.1.0.tgz"}}, {Version: "0.0.1", URLs: []string{"https://example.com/my-chart-0.0.1.tgz"}}}}, + http.StatusTemporaryRedirect, + "https://example.com/my-chart-0.1.0.tgz", + }, + { + "chart exists with trailing /", + nil, + models.Chart{ID: "my-repo/my-chart/", ChartVersions: []models.ChartVersion{{Version: "0.1.0", URLs: []string{"https://example.com/my-chart-0.1.0.tgz"}}, {Version: "0.0.1", URLs: []string{"https://example.com/my-chart-0.0.1.tgz"}}}}, + http.StatusTemporaryRedirect, + "https://example.com/my-chart-0.1.0.tgz", + }, + { + "chart with version", + nil, + models.Chart{ID: "my-repo/my-chart/0.1.0", ChartVersions: []models.ChartVersion{{Version: "0.1.0", URLs: []string{"https://example.com/my-chart-0.1.0.tgz"}}}}, + http.StatusTemporaryRedirect, + "https://example.com/my-chart-0.1.0.tgz", + }, + { + "chart with version with trailing /", + nil, + models.Chart{ID: "my-repo/my-chart/0.1.0/", ChartVersions: []models.ChartVersion{{Version: "0.1.0", URLs: []string{"https://example.com/my-chart-0.1.0.tgz"}}}}, + http.StatusTemporaryRedirect, + "https://example.com/my-chart-0.1.0.tgz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var m mock.Mock + dbSession = mockstore.NewMockSession(&m) + + if tt.err != nil { + m.On("One", mock.Anything).Return(tt.err) + } else { + m.On("One", &models.Chart{}).Return(nil).Run(func(args mock.Arguments) { + *args.Get(0).(*models.Chart) = tt.chart + }) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/v1/redirect/charts/"+tt.chart.ID, nil) + + redirectToChartVersionPackage(w, req) + + m.AssertExpectations(t) + assert.Equal(t, tt.wantCode, w.Code) + if tt.wantCode == http.StatusTemporaryRedirect { + resp := w.Result() + assert.Equal(t, tt.location, resp.Header.Get("Location"), "response header location should be chart url") + } + + // Check for provenance file + w = httptest.NewRecorder() + req = httptest.NewRequest("GET", "/v1/redirect/charts/"+tt.chart.ID+".prov", nil) + + redirectToChartVersionPackage(w, req) + + m.AssertExpectations(t) + assert.Equal(t, tt.wantCode, w.Code) + if tt.wantCode == http.StatusTemporaryRedirect { + resp := w.Result() + assert.Equal(t, tt.location+".prov", resp.Header.Get("Location"), "response header location should be chart provenance url") + } + }) + } +} diff --git a/cmd/chartsvc/main.go b/cmd/chartsvc/main.go index 13669c4f3..e2ce15c26 100644 --- a/cmd/chartsvc/main.go +++ b/cmd/chartsvc/main.go @@ -61,6 +61,11 @@ func setupRoutes() http.Handler { apiv1.Methods("GET").Path("/assets/{repo}/{chartName}/versions/{version}/values.yaml").Handler(WithParams(getChartVersionValues)) apiv1.Methods("GET").Path("/assets/{repo}/{chartName}/versions/{version}/values.schema.json").Handler(WithParams(getChartVersionSchema)) + // Handle redirects to the root chart. That way you can + // `helm install monocular.example.com/charts/foo/bar` and have monocular + // redirect to the right place. + apiv1.Methods("GET").PathPrefix("/redirect").HandlerFunc(redirectToChartVersionPackage) + n := negroni.Classic() n.UseHandler(r) return n