sigs.k8s.io/cluster-api@v1.7.1/internal/goproxy/goproxy.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package goproxy
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/url"
    25  	"path"
    26  	"path/filepath"
    27  	"sort"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/blang/semver/v4"
    32  	"github.com/pkg/errors"
    33  	"k8s.io/apimachinery/pkg/util/wait"
    34  )
    35  
    36  const (
    37  	defaultGoProxyHost = "proxy.golang.org"
    38  )
    39  
    40  var (
    41  	retryableOperationInterval = 10 * time.Second
    42  	retryableOperationTimeout  = 1 * time.Minute
    43  )
    44  
    45  // Client is a client to query versions from a goproxy instance.
    46  type Client struct {
    47  	scheme string
    48  	host   string
    49  }
    50  
    51  // NewClient returns a new goproxyClient instance.
    52  func NewClient(scheme, host string) *Client {
    53  	return &Client{
    54  		scheme: scheme,
    55  		host:   host,
    56  	}
    57  }
    58  
    59  // GetVersions returns the a sorted list of semantical versions which exist for a go module.
    60  func (g *Client) GetVersions(ctx context.Context, gomodulePath string) (semver.Versions, error) {
    61  	parsedVersions := semver.Versions{}
    62  
    63  	majorVersionNumber := 1
    64  	var majorVersion string
    65  	for {
    66  		if majorVersionNumber > 1 {
    67  			majorVersion = fmt.Sprintf("v%d", majorVersionNumber)
    68  		}
    69  		rawURL := url.URL{
    70  			Scheme: g.scheme,
    71  			Host:   g.host,
    72  			Path:   path.Join(gomodulePath, majorVersion, "@v", "/list"),
    73  		}
    74  		majorVersionNumber++
    75  
    76  		req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL.String(), http.NoBody)
    77  		if err != nil {
    78  			return nil, errors.Wrapf(err, "failed to get versions: failed to create request")
    79  		}
    80  
    81  		var rawResponse []byte
    82  		var responseStatusCode int
    83  		var retryError error
    84  		_ = wait.PollUntilContextTimeout(ctx, retryableOperationInterval, retryableOperationTimeout, true, func(context.Context) (bool, error) {
    85  			retryError = nil
    86  
    87  			resp, err := http.DefaultClient.Do(req)
    88  			if err != nil {
    89  				retryError = errors.Wrapf(err, "failed to get versions: failed to do request")
    90  				return false, nil
    91  			}
    92  			defer resp.Body.Close()
    93  
    94  			responseStatusCode = resp.StatusCode
    95  
    96  			// Status codes OK and NotFound are expected results:
    97  			// * OK indicates that we got a list of versions to read.
    98  			// * NotFound indicates that there are no versions for this module / modules major version.
    99  			if responseStatusCode != http.StatusOK && responseStatusCode != http.StatusNotFound {
   100  				retryError = errors.Errorf("failed to get versions: response status code %d", resp.StatusCode)
   101  				return false, nil
   102  			}
   103  
   104  			// only read the response for http.StatusOK
   105  			if responseStatusCode == http.StatusOK {
   106  				rawResponse, err = io.ReadAll(resp.Body)
   107  				if err != nil {
   108  					retryError = errors.Wrap(err, "failed to get versions: error reading goproxy response body")
   109  					return false, nil
   110  				}
   111  			}
   112  			return true, nil
   113  		})
   114  		if retryError != nil {
   115  			return nil, retryError
   116  		}
   117  
   118  		// Don't try to read the versions if status was not found.
   119  		if responseStatusCode == http.StatusNotFound {
   120  			break
   121  		}
   122  
   123  		for _, s := range strings.Split(string(rawResponse), "\n") {
   124  			if s == "" {
   125  				continue
   126  			}
   127  			parsedVersion, err := semver.ParseTolerant(s)
   128  			if err != nil {
   129  				// Discard releases with tags that are not a valid semantic versions (the user can point explicitly to such releases).
   130  				continue
   131  			}
   132  			parsedVersions = append(parsedVersions, parsedVersion)
   133  		}
   134  	}
   135  
   136  	if len(parsedVersions) == 0 {
   137  		return nil, fmt.Errorf("no versions found for go module %q", gomodulePath)
   138  	}
   139  
   140  	sort.Sort(parsedVersions)
   141  
   142  	return parsedVersions, nil
   143  }
   144  
   145  // GetSchemeAndHost detects and returns the scheme and host for goproxy requests.
   146  // It returns empty strings if goproxy is disabled via `off` or `direct` values.
   147  func GetSchemeAndHost(goproxy string) (string, string, error) {
   148  	// Fallback to default
   149  	if goproxy == "" {
   150  		return "https", defaultGoProxyHost, nil
   151  	}
   152  
   153  	var goproxyHost, goproxyScheme string
   154  	// xref https://github.com/golang/go/blob/master/src/cmd/go/internal/modfetch/proxy.go
   155  	for goproxy != "" {
   156  		var rawURL string
   157  		if i := strings.IndexAny(goproxy, ",|"); i >= 0 {
   158  			rawURL = goproxy[:i]
   159  			goproxy = goproxy[i+1:]
   160  		} else {
   161  			rawURL = goproxy
   162  			goproxy = ""
   163  		}
   164  
   165  		rawURL = strings.TrimSpace(rawURL)
   166  		if rawURL == "" {
   167  			continue
   168  		}
   169  		if rawURL == "off" || rawURL == "direct" {
   170  			// Return nothing to fallback to github repository client without an error.
   171  			return "", "", nil
   172  		}
   173  
   174  		// Single-word tokens are reserved for built-in behaviors, and anything
   175  		// containing the string ":/" or matching an absolute file path must be a
   176  		// complete URL. For all other paths, implicitly add "https://".
   177  		if strings.ContainsAny(rawURL, ".:/") && !strings.Contains(rawURL, ":/") && !filepath.IsAbs(rawURL) && !path.IsAbs(rawURL) {
   178  			rawURL = "https://" + rawURL
   179  		}
   180  
   181  		parsedURL, err := url.Parse(rawURL)
   182  		if err != nil {
   183  			return "", "", errors.Wrapf(err, "parse GOPROXY url %q", rawURL)
   184  		}
   185  		goproxyHost = parsedURL.Host
   186  		goproxyScheme = parsedURL.Scheme
   187  		// A host was found so no need to continue.
   188  		break
   189  	}
   190  
   191  	return goproxyScheme, goproxyHost, nil
   192  }