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 9, 2022
1 parent d64c4d4 commit cc65b30
Show file tree
Hide file tree
Showing 5 changed files with 332 additions and 69 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(), "")
// We replace "latest" in the url by the wanted version to disable auto version-discovery in repositoryClientFactory.
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
125 changes: 125 additions & 0 deletions cmd/clusterctl/client/repository/goproxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
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"
"fmt"
"io"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"

"github.com/pkg/errors"
)

type goproxyClient struct {
scheme string
host string
}

func newGoproxyClient(scheme, host string) *goproxyClient {
return &goproxyClient{
scheme: scheme,
host: host,
}
}

func (g *goproxyClient) List(owner, repository string) ([]byte, error) {
// build the naive path
var gomodulePath string
switch owner {
case "kubernetes-sigs":
gomodulePath = path.Join("sigs.k8s.io", repository)
case "kubernetes":
gomodulePath = path.Join("k8s.io", repository)
default:
gomodulePath = path.Join("github.com", owner, repository)
}

rawURL := url.URL{
Scheme: g.scheme,
Host: g.host,
Path: path.Join(gomodulePath, "@v", "/list"),
}

req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, rawURL.String(), http.NoBody)
if err != nil {
return nil, errors.Wrapf(err, "failed to get versions for go module %q via goproxy %q: failed to create request", gomodulePath, g.host)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, errors.Wrapf(err, "failed to get versions for go module %q via goproxy %q", gomodulePath, g.host)
}
defer resp.Body.Close()

rawResponse, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "error read goproxy response body")
}
return rawResponse, nil
}

func getGoproxyHost(goproxy string) (string, string, error) {
// fallback default
if goproxy == "" {
return "https", "proxy.golang.org", nil
}

var goproxyHost, goproxyScheme string
// Use url from GOPROXY variable if set.
// 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
}
if rawURL == "off" || rawURL == "direct" {
return "", "", fmt.Errorf("expected GOPROXY environment variable to not be set to off or direct")
}

// 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
}
94 changes: 94 additions & 0 deletions cmd/clusterctl/client/repository/goproxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
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"

func Test_getGoproxyHost(t *testing.T) {
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)
}
})
}
}
63 changes: 45 additions & 18 deletions cmd/clusterctl/client/repository/repository_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/blang/semver"
"github.com/google/go-github/v45/github"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/apimachinery/pkg/util/wait"

clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
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,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
Expand Down Expand Up @@ -201,6 +210,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 @@ -216,21 +237,20 @@ func (g *gitHubRepository) getVersions() ([]string, error) {
return versions, nil
}

client := g.getClient()
versionClient, err := g.getGoproxyClient()
if err != nil {
return nil, errors.Wrap(err, "get versions client")
}

// get all the releases
// NB. currently Github API does not support result ordering, so it not possible to limit results
var releases []*github.RepositoryRelease
var rawResponse []byte
var retryError error
_ = wait.PollImmediate(retryableOperationInterval, retryableOperationTimeout, func() (bool, error) {
var listReleasesErr error
releases, _, listReleasesErr = client.Repositories.ListReleases(context.TODO(), g.owner, g.repository, nil)
if listReleasesErr != nil {
retryError = g.handleGithubErr(listReleasesErr, "failed to get the list of releases")
// return immediately if we are rate limited
if _, ok := listReleasesErr.(*github.RateLimitError); ok {
return false, retryError
}
var retryErr error
rawResponse, retryErr = versionClient.List(g.owner, g.repository)
if retryErr != nil {
retryError = errors.Wrapf(retryErr, "failed to get versions owner=%s, repository=%s", g.owner, g.repository)
return false, nil
}
retryError = nil
Expand All @@ -239,18 +259,25 @@ func (g *gitHubRepository) getVersions() ([]string, error) {
if retryError != nil {
return nil, retryError
}
versions := []string{}
for _, r := range releases {
r := r // pin
if r.TagName == nil {

parsedVersions := semver.Versions{}

for _, s := range strings.Split(string(rawResponse), "\n") {
if s == "" {
continue
}
tagName := *r.TagName
if _, err := version.ParseSemantic(tagName); err != nil {
parsedVersion, err := semver.Parse(strings.TrimPrefix(s, "v"))
if err != nil {
// Discard releases with tags that are not a valid semantic versions (the user can point explicitly to such releases).
continue
}
versions = append(versions, tagName)
parsedVersions = append(parsedVersions, parsedVersion)
}
sort.Sort(parsedVersions)

versions := []string{}
for _, v := range parsedVersions {
versions = append(versions, "v"+v.String())
}

cacheVersions[cacheID] = versions
Expand Down
Loading

0 comments on commit cc65b30

Please sign in to comment.