diff --git a/cmd/clusterctl/client/cluster/cert_manager.go b/cmd/clusterctl/client/cluster/cert_manager.go index 5539845eee7c..a55f9737dd1f 100644 --- a/cmd/clusterctl/client/cluster/cert_manager.go +++ b/cmd/clusterctl/client/cluster/cert_manager.go @@ -19,6 +19,7 @@ package cluster import ( "context" _ "embed" + "strings" "time" "github.com/blang/semver" @@ -401,7 +402,9 @@ func (cm *certManagerClient) getManifestObjs(certManagerConfig config.CertManage // Given that cert manager components yaml are stored in a repository like providers components yaml, // we are using the same machinery to retrieve the file by using a fake provider object using // the cert manager repository url. - certManagerFakeProvider := config.NewProvider("cert-manager", certManagerConfig.URL(), "") + // In order to avoid unnecessary calls to GitHub API, "latest" is replace with the version currently installed by clusterctl. + url := strings.Replace(certManagerConfig.URL(), "latest", certManagerConfig.Version(), 1) + certManagerFakeProvider := config.NewProvider("cert-manager", url, "") certManagerRepository, err := cm.repositoryClientFactory(certManagerFakeProvider, cm.configClient) if err != nil { return nil, err diff --git a/cmd/clusterctl/client/repository/goproxy.go b/cmd/clusterctl/client/repository/goproxy.go new file mode 100644 index 000000000000..479d946509a4 --- /dev/null +++ b/cmd/clusterctl/client/repository/goproxy.go @@ -0,0 +1,150 @@ +/* +Copyright 2022 The Kubernetes 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 repository + +import ( + "context" + "io" + "net/http" + "net/url" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/blang/semver" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/util/wait" +) + +type goproxyClient struct { + scheme string + host string +} + +func newGoproxyClient(scheme, host string) *goproxyClient { + return &goproxyClient{ + scheme: scheme, + host: host, + } +} + +func (g *goproxyClient) getVersions(owner, repository string) ([]string, error) { + // A goproxy is also able to handle the github repository path instead of the actual go module name. + gomodulePath := path.Join("github.com", owner, repository) + + rawURL := url.URL{ + Scheme: g.scheme, + Host: g.host, + Path: path.Join(gomodulePath, "@v", "/list"), + } + + var rawResponse []byte + var retryError error + _ = wait.PollImmediate(retryableOperationInterval, retryableOperationTimeout, func() (bool, error) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, rawURL.String(), http.NoBody) + if err != nil { + return false, errors.Wrapf(err, "failed to create request", gomodulePath, g.host) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + retryError = errors.Wrapf(err, "failed to get versions for go module %q via goproxy %q", gomodulePath, g.host) + return false, nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + retryError = errors.Errorf("Request status code %d: %s", resp.StatusCode, resp.Status) + return false, nil + } + + rawResponse, err = io.ReadAll(resp.Body) + if err != nil { + return false, errors.Wrap(err, "error reading goproxy response body") + } + return true, nil + }) + if retryError != nil { + return nil, retryError + } + + parsedVersions := semver.Versions{} + for _, s := range strings.Split(string(rawResponse), "\n") { + if s == "" { + continue + } + parsedVersion, err := semver.ParseTolerant(s) + if err != nil { + // Discard releases with tags that are not a valid semantic versions (the user can point explicitly to such releases). + continue + } + parsedVersions = append(parsedVersions, parsedVersion) + } + + sort.Sort(parsedVersions) + + versions := []string{} + for _, v := range parsedVersions { + versions = append(versions, "v"+v.String()) + } + + return versions, nil +} + +func getGoproxyHost(goproxy string) (string, string, error) { + // fallback default + if goproxy == "" { + return "https", "proxy.golang.org", nil + } + + var goproxyHost, goproxyScheme string + // xref https://github.com/golang/go/blob/master/src/cmd/go/internal/modfetch/proxy.go + for goproxy != "" { + var rawURL string + if i := strings.IndexAny(goproxy, ",|"); i >= 0 { + rawURL = goproxy[:i] + goproxy = goproxy[i+1:] + } else { + rawURL = goproxy + goproxy = "" + } + + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + continue + } + + // Single-word tokens are reserved for built-in behaviors, and anything + // containing the string ":/" or matching an absolute file path must be a + // complete URL. For all other paths, implicitly add "https://". + if strings.ContainsAny(rawURL, ".:/") && !strings.Contains(rawURL, ":/") && !filepath.IsAbs(rawURL) && !path.IsAbs(rawURL) { + rawURL = "https://" + rawURL + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", "", errors.Wrapf(err, "parse GOPROXY url %q", rawURL) + } + goproxyHost = parsedURL.Host + goproxyScheme = parsedURL.Scheme + // A host was found so no need to continue. + break + } + + return goproxyScheme, goproxyHost, nil +} diff --git a/cmd/clusterctl/client/repository/goproxy_test.go b/cmd/clusterctl/client/repository/goproxy_test.go new file mode 100644 index 000000000000..aa4bbb927215 --- /dev/null +++ b/cmd/clusterctl/client/repository/goproxy_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2022 The Kubernetes 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 repository + +import ( + "testing" + "time" +) + +func Test_getGoproxyHost(t *testing.T) { + retryableOperationInterval = 200 * time.Millisecond + retryableOperationTimeout = 1 * time.Second + + tests := []struct { + name string + envvar string + wantScheme string + wantHost string + wantErr bool + }{ + { + name: "defaulting", + envvar: "", + wantScheme: "https", + wantHost: "proxy.golang.org", + wantErr: false, + }, + { + name: "direct is unsupported", + envvar: "direct", + wantScheme: "", + wantHost: "", + wantErr: true, + }, + { + name: "off is unsupported", + envvar: "off", + wantScheme: "", + wantHost: "", + wantErr: true, + }, + { + name: "other goproxy", + envvar: "foo.bar.de", + wantScheme: "https", + wantHost: "foo.bar.de", + wantErr: false, + }, + { + name: "other goproxy comma separated, return first", + envvar: "foo.bar,foobar.barfoo", + wantScheme: "https", + wantHost: "foo.bar", + wantErr: false, + }, + { + name: "other goproxy including https scheme", + envvar: "https://foo.bar", + wantScheme: "https", + wantHost: "foo.bar", + wantErr: false, + }, + { + name: "other goproxy including http scheme", + envvar: "http://foo.bar", + wantScheme: "http", + wantHost: "foo.bar", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotScheme, gotHost, err := getGoproxyHost(tt.envvar) + if (err != nil) != tt.wantErr { + t.Errorf("getGoproxyHost() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotScheme != tt.wantScheme { + t.Errorf("getGoproxyHost() = %v, wantScheme %v", gotScheme, tt.wantScheme) + } + if gotHost != tt.wantHost { + t.Errorf("getGoproxyHost() = %v, wantHost %v", gotHost, tt.wantHost) + } + }) + } +} diff --git a/cmd/clusterctl/client/repository/repository_github.go b/cmd/clusterctl/client/repository/repository_github.go index 3e5d506ba095..2678f63dbdc6 100644 --- a/cmd/clusterctl/client/repository/repository_github.go +++ b/cmd/clusterctl/client/repository/repository_github.go @@ -22,6 +22,7 @@ import ( "io" "net/http" "net/url" + "os" "path/filepath" "strings" "time" @@ -34,6 +35,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" + logf "sigs.k8s.io/cluster-api/cmd/clusterctl/log" ) const ( @@ -68,6 +70,7 @@ type gitHubRepository struct { rootPath string componentsPath string injectClient *github.Client + injectGoproxyClient *goproxyClient } var _ Repository = &gitHubRepository{} @@ -80,6 +83,12 @@ func injectGithubClient(c *github.Client) githubRepositoryOption { } } +func injectGoproxyClient(c *goproxyClient) githubRepositoryOption { + return func(g *gitHubRepository) { + g.injectGoproxyClient = c + } +} + // DefaultVersion returns defaultVersion field of gitHubRepository struct. func (g *gitHubRepository) DefaultVersion() string { return g.defaultVersion @@ -87,10 +96,28 @@ func (g *gitHubRepository) DefaultVersion() string { // GetVersions returns the list of versions that are available in a provider repository. func (g *gitHubRepository) GetVersions() ([]string, error) { - versions, err := g.getVersions() + log := logf.Log + + cacheID := fmt.Sprintf("%s/%s", g.owner, g.repository) + if versions, ok := cacheVersions[cacheID]; ok { + return versions, nil + } + + goProxyClient, err := g.getGoproxyClient() if err != nil { - return nil, errors.Wrapf(err, "failed to get repository versions") + return nil, errors.Wrap(err, "get versions client") + } + + versions, err := goProxyClient.getVersions(g.owner, g.repository) + if err != nil { + log.V(5).Info("error using Goproxy client to list versions for repository, falling back to github client", "owner", g.owner, "repository", g.repository, "error", err) + versions, err = g.getVersions() + if err != nil { + return nil, errors.Wrapf(err, "failed to get repository versions") + } } + + cacheVersions[cacheID] = versions return versions, nil } @@ -201,6 +228,18 @@ func (g *gitHubRepository) getClient() *github.Client { return github.NewClient(g.authenticatingHTTPClient) } +// getGoproxyClient returns a go proxy client. +func (g *gitHubRepository) getGoproxyClient() (*goproxyClient, error) { + if g.injectGoproxyClient != nil { + return g.injectGoproxyClient, nil + } + scheme, host, err := getGoproxyHost(os.Getenv("GOPROXY")) + if err != nil { + return nil, err + } + return newGoproxyClient(scheme, host), nil +} + // setClientToken sets authenticatingHTTPClient field of gitHubRepository struct. func (g *gitHubRepository) setClientToken(token string) { ts := oauth2.StaticTokenSource( @@ -211,11 +250,6 @@ func (g *gitHubRepository) setClientToken(token string) { // getVersions returns all the release versions for a github repository. func (g *gitHubRepository) getVersions() ([]string, error) { - cacheID := fmt.Sprintf("%s/%s", g.owner, g.repository) - if versions, ok := cacheVersions[cacheID]; ok { - return versions, nil - } - client := g.getClient() // get all the releases @@ -253,7 +287,6 @@ func (g *gitHubRepository) getVersions() ([]string, error) { versions = append(versions, tagName) } - cacheVersions[cacheID] = versions return versions, nil } diff --git a/cmd/clusterctl/client/repository/repository_github_test.go b/cmd/clusterctl/client/repository/repository_github_test.go index faef2373a8e5..97bc5a760bea 100644 --- a/cmd/clusterctl/client/repository/repository_github_test.go +++ b/cmd/clusterctl/client/repository/repository_github_test.go @@ -19,6 +19,8 @@ package repository import ( "fmt" "net/http" + "net/http/httptest" + "net/url" "testing" "time" @@ -31,6 +33,81 @@ import ( "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test" ) +func Test_gitHubRepository_GetVersions(t *testing.T) { + retryableOperationInterval = 200 * time.Millisecond + retryableOperationTimeout = 1 * time.Second + + client, mux, teardown := test.NewFakeGitHub() + defer teardown() + + // setup an handler for returning 5 fake releases + mux.HandleFunc("/repos/o/r1/releases", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `[`) + fmt.Fprint(w, `{"id":1, "tag_name": "v0.4.0"},`) + fmt.Fprint(w, `{"id":2, "tag_name": "v0.4.1"},`) + fmt.Fprint(w, `{"id":3, "tag_name": "v0.4.2"},`) + fmt.Fprint(w, `{"id":4, "tag_name": "v0.4.3-alpha"}`) // prerelease + fmt.Fprint(w, `]`) + }) + + clientGoproxy, muxGoproxy, teardownGoproxy := newFakeGoproxy() + defer teardownGoproxy() + + // setup an handler for returning 4 fake releases + muxGoproxy.HandleFunc("/github.com/o/r2/@v/list", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, "v0.5.0\n") + fmt.Fprint(w, "v0.4.0\n") + fmt.Fprint(w, "v0.3.2\n") + fmt.Fprint(w, "v0.3.1\n") + }) + + configVariablesClient := test.NewFakeVariableClient() + + tests := []struct { + name string + providerConfig config.Provider + want []string + wantErr bool + }{ + { + name: "fallback to github", + providerConfig: config.NewProvider("test", "https://github.com/o/r1/releases/v0.4.0/path", clusterctlv1.CoreProviderType), + want: []string{"v0.4.0", "v0.4.1", "v0.4.2", "v0.4.3-alpha"}, + wantErr: false, + }, + { + name: "use goproxy", + providerConfig: config.NewProvider("test", "https://github.com/o/r2/releases/v0.4.0/path", clusterctlv1.CoreProviderType), + want: []string{"v0.3.1", "v0.3.2", "v0.4.0", "v0.5.0"}, + wantErr: false, + }, + { + name: "failure", + providerConfig: config.NewProvider("test", "https://github.com/o/unknown/releases/v0.4.0/path", clusterctlv1.CoreProviderType), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + resetCaches() + + gRepo, err := NewGitHubRepository(tt.providerConfig, configVariablesClient, injectGithubClient(client), injectGoproxyClient(clientGoproxy)) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := gRepo.GetVersions() + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + func Test_githubRepository_newGitHubRepository(t *testing.T) { retryableOperationInterval = 200 * time.Millisecond retryableOperationTimeout = 1 * time.Second @@ -296,17 +373,6 @@ func Test_gitHubRepository_getLatestContractRelease(t *testing.T) { client, mux, teardown := test.NewFakeGitHub() defer teardown() - // setup an handler for returning 3 fake releases - mux.HandleFunc("/repos/o/r1/releases", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - fmt.Fprint(w, `[`) - fmt.Fprint(w, `{"id":1, "tag_name": "v0.5.0", "assets": [{"id": 1, "name": "metadata.yaml"}]},`) - fmt.Fprint(w, `{"id":2, "tag_name": "v0.4.0", "assets": [{"id": 1, "name": "metadata.yaml"}]},`) - fmt.Fprint(w, `{"id":3, "tag_name": "v0.3.2", "assets": [{"id": 1, "name": "metadata.yaml"}]},`) - fmt.Fprint(w, `{"id":4, "tag_name": "v0.3.1", "assets": [{"id": 1, "name": "metadata.yaml"}]}`) - fmt.Fprint(w, `]`) - }) - // test.NewFakeGitHub and handler for returning a fake release mux.HandleFunc("/repos/o/r1/releases/tags/v0.5.0", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") @@ -321,6 +387,18 @@ func Test_gitHubRepository_getLatestContractRelease(t *testing.T) { fmt.Fprint(w, "apiVersion: clusterctl.cluster.x-k8s.io/v1alpha3\nreleaseSeries:\n - major: 0\n minor: 4\n contract: v1alpha4\n - major: 0\n minor: 5\n contract: v1alpha4\n - major: 0\n minor: 3\n contract: v1alpha3\n") }) + clientGoproxy, muxGoproxy, teardownGoproxy := newFakeGoproxy() + defer teardownGoproxy() + + // setup an handler for returning 4 fake releases + muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, "v0.5.0\n") + fmt.Fprint(w, "v0.4.0\n") + fmt.Fprint(w, "v0.3.2\n") + fmt.Fprint(w, "v0.3.1\n") + }) + configVariablesClient := test.NewFakeVariableClient() type field struct { @@ -366,7 +444,7 @@ func Test_gitHubRepository_getLatestContractRelease(t *testing.T) { g := NewWithT(t) resetCaches() - gRepo, err := NewGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client)) + gRepo, err := NewGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client), injectGoproxyClient(clientGoproxy)) g.Expect(err).NotTo(HaveOccurred()) got, err := latestContractRelease(gRepo, tt.contract) @@ -381,34 +459,32 @@ func Test_gitHubRepository_getLatestContractRelease(t *testing.T) { } func Test_gitHubRepository_getLatestRelease(t *testing.T) { - client, mux, teardown := test.NewFakeGitHub() - defer teardown() + retryableOperationInterval = 200 * time.Millisecond + retryableOperationTimeout = 1 * time.Second + clientGoproxy, muxGoproxy, teardownGoproxy := newFakeGoproxy() + defer teardownGoproxy() // setup an handler for returning 4 fake releases - mux.HandleFunc("/repos/o/r1/releases", func(w http.ResponseWriter, r *http.Request) { + muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") - fmt.Fprint(w, `[`) - fmt.Fprint(w, `{"id":1, "tag_name": "v0.4.1"},`) - fmt.Fprint(w, `{"id":2, "tag_name": "v0.4.2"},`) - fmt.Fprint(w, `{"id":3, "tag_name": "v0.4.3-alpha"},`) // prerelease - fmt.Fprint(w, `{"id":4, "tag_name": "foo"}`) // no semantic version tag - fmt.Fprint(w, `]`) + fmt.Fprint(w, "v0.4.1\n") + fmt.Fprint(w, "v0.4.2\n") + fmt.Fprint(w, "v0.4.3-alpha\n") // prerelease + fmt.Fprint(w, "foo\n") // no semantic version tag }) // setup an handler for returning no releases - mux.HandleFunc("/repos/o/r2/releases", func(w http.ResponseWriter, r *http.Request) { + muxGoproxy.HandleFunc("/github.com/o/r2/@v/list", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") // no releases }) // setup an handler for returning fake prereleases only - mux.HandleFunc("/repos/o/r3/releases", func(w http.ResponseWriter, r *http.Request) { + muxGoproxy.HandleFunc("/github.com/o/r3/@v/list", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") - fmt.Fprint(w, `[`) - fmt.Fprint(w, `{"id":1, "tag_name": "v0.1.0-alpha.0"},`) - fmt.Fprint(w, `{"id":2, "tag_name": "v0.1.0-alpha.1"},`) - fmt.Fprint(w, `{"id":3, "tag_name": "v0.1.0-alpha.2"}`) - fmt.Fprint(w, `]`) + fmt.Fprint(w, "v0.1.0-alpha.0\n") + fmt.Fprint(w, "v0.1.0-alpha.1\n") + fmt.Fprint(w, "v0.1.0-alpha.2\n") }) configVariablesClient := test.NewFakeVariableClient() @@ -452,7 +528,7 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) { g := NewWithT(t) resetCaches() - gRepo, err := NewGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client)) + gRepo, err := NewGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGoproxyClient(clientGoproxy)) g.Expect(err).NotTo(HaveOccurred()) got, err := latestRelease(gRepo) @@ -468,17 +544,17 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) { } func Test_gitHubRepository_getLatestPatchRelease(t *testing.T) { - client, mux, teardown := test.NewFakeGitHub() - defer teardown() + retryableOperationInterval = 200 * time.Millisecond + retryableOperationTimeout = 1 * time.Second + clientGoproxy, muxGoproxy, teardownGoproxy := newFakeGoproxy() + defer teardownGoproxy() - // setup an handler for returning 3 fake releases - mux.HandleFunc("/repos/o/r1/releases", func(w http.ResponseWriter, r *http.Request) { + // setup an handler for returning 4 fake releases + muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") - fmt.Fprint(w, `[`) - fmt.Fprint(w, `{"id":1, "tag_name": "v0.4.0"},`) - fmt.Fprint(w, `{"id":2, "tag_name": "v0.3.2"},`) - fmt.Fprint(w, `{"id":3, "tag_name": "v1.3.2"}`) - fmt.Fprint(w, `]`) + fmt.Fprint(w, "v0.4.0\n") + fmt.Fprint(w, "v0.3.2\n") + fmt.Fprint(w, "v1.3.2\n") }) major0 := uint(0) @@ -534,7 +610,7 @@ func Test_gitHubRepository_getLatestPatchRelease(t *testing.T) { g := NewWithT(t) resetCaches() - gRepo, err := NewGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGithubClient(client)) + gRepo, err := NewGitHubRepository(tt.field.providerConfig, configVariablesClient, injectGoproxyClient(clientGoproxy)) g.Expect(err).NotTo(HaveOccurred()) got, err := latestPatchRelease(gRepo, tt.major, tt.minor) @@ -732,3 +808,21 @@ func resetCaches() { cacheReleases = map[string]*github.RepositoryRelease{} cacheFiles = map[string][]byte{} } + +// newFakeGoproxy sets up a test HTTP server along with a github.Client that is +// configured to talk to that test server. Tests should register handlers on +// mux which provide mock responses for the API method being tested. +func newFakeGoproxy() (client *goproxyClient, mux *http.ServeMux, teardown func()) { + // mux is the HTTP request multiplexer used with the test server. + mux = http.NewServeMux() + + apiHandler := http.NewServeMux() + apiHandler.Handle("/", mux) + + // server is a test HTTP server used to provide mock API responses. + server := httptest.NewServer(apiHandler) + + // client is the GitHub client being tested and is configured to use test server. + url, _ := url.Parse(server.URL + "/") + return &goproxyClient{scheme: url.Scheme, host: url.Host}, mux, server.Close +}