Skip to content

Commit

Permalink
Reduce github api requests in clusterctl
Browse files Browse the repository at this point in the history
* Removes additional cert-manager latest version detection because it always gets overwritten.
* Uses goproxy instead of github api for listing repository versions.
  • Loading branch information
chrischdi committed Sep 23, 2022
1 parent 62e6600 commit 0101c0b
Show file tree
Hide file tree
Showing 5 changed files with 428 additions and 48 deletions.
5 changes: 4 additions & 1 deletion cmd/clusterctl/client/cluster/cert_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package cluster
import (
"context"
_ "embed"
"strings"
"time"

"github.com/blang/semver"
Expand Down Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions cmd/clusterctl/client/repository/goproxy.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions cmd/clusterctl/client/repository/goproxy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
49 changes: 41 additions & 8 deletions cmd/clusterctl/client/repository/repository_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
Expand All @@ -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 (
Expand Down Expand Up @@ -68,6 +70,7 @@ type gitHubRepository struct {
rootPath string
componentsPath string
injectClient *github.Client
injectGoproxyClient *goproxyClient
}

var _ Repository = &gitHubRepository{}
Expand All @@ -80,17 +83,41 @@ 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
}

// 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
}

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -253,7 +287,6 @@ func (g *gitHubRepository) getVersions() ([]string, error) {
versions = append(versions, tagName)
}

cacheVersions[cacheID] = versions
return versions, nil
}

Expand Down
Loading

0 comments on commit 0101c0b

Please sign in to comment.