Skip to content

Commit b46a7b5

Browse files
authored
Merge pull request #10618 from chrischdi/pr-test-resolverelease-check-metadata
🌱 test: check for metadata.yaml when resolving releases to not try to use unreleased versions
2 parents a7a32ed + 361482f commit b46a7b5

File tree

3 files changed

+193
-16
lines changed

3 files changed

+193
-16
lines changed

cmd/clusterctl/client/repository/repository_github.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,12 @@ func (g *gitHubRepository) httpGetFilesFromRelease(ctx context.Context, version,
414414
}
415415
defer resp.Body.Close()
416416

417+
// if we get 404 there is no reason to retry
418+
if resp.StatusCode == http.StatusNotFound {
419+
retryError = errNotFound
420+
return true, nil
421+
}
422+
417423
if resp.StatusCode != http.StatusOK {
418424
retryError = errors.Errorf("error getting file, status code: %d", resp.StatusCode)
419425
return false, nil

test/framework/clusterctl/e2e_config.go

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package clusterctl
1919
import (
2020
"context"
2121
"fmt"
22+
"io"
23+
"net/http"
2224
"net/url"
2325
"os"
2426
"path/filepath"
@@ -33,6 +35,7 @@ import (
3335
. "github.com/onsi/gomega"
3436
"github.com/pkg/errors"
3537
"k8s.io/apimachinery/pkg/util/version"
38+
"k8s.io/apimachinery/pkg/util/wait"
3639
"k8s.io/utils/ptr"
3740
"sigs.k8s.io/yaml"
3841

@@ -304,13 +307,16 @@ func ResolveRelease(ctx context.Context, releaseMarker string) (string, error) {
304307
return "", errors.Errorf("releasemarker does not support disabling the go proxy: GOPROXY=%q", os.Getenv("GOPROXY"))
305308
}
306309
goproxyClient := goproxy.NewClient(scheme, host)
307-
return resolveReleaseMarker(ctx, releaseMarker, goproxyClient)
310+
return resolveReleaseMarker(ctx, releaseMarker, goproxyClient, githubReleaseMetadataURL)
308311
}
309312

310313
// resolveReleaseMarker resolves releaseMarker string to verion string e.g.
311314
// - Resolves "go://sigs.k8s.io/cluster-api@v1.0" to the latest stable patch release of v1.0.
312315
// - Resolves "go://sigs.k8s.io/cluster-api@latest-v1.0" to the latest patch release of v1.0 including rc and pre releases.
313-
func resolveReleaseMarker(ctx context.Context, releaseMarker string, goproxyClient *goproxy.Client) (string, error) {
316+
// It also checks if a release actually exists by trying to query for the `metadata.yaml` file.
317+
// To do that the url gets calculated by the toMetadataURL function. The toMetadataURL func
318+
// is passed as variable to be able to write a proper unit test.
319+
func resolveReleaseMarker(ctx context.Context, releaseMarker string, goproxyClient *goproxy.Client, toMetadataURL func(gomodule, version string) string) (string, error) {
314320
if !strings.HasPrefix(releaseMarker, "go://") {
315321
return "", errors.Errorf("unknown release marker scheme")
316322
}
@@ -330,31 +336,131 @@ func resolveReleaseMarker(ctx context.Context, releaseMarker string, goproxyClie
330336
if strings.HasPrefix(gomoduleParts[1], "latest-") {
331337
includePrereleases = true
332338
}
333-
version := strings.TrimPrefix(gomoduleParts[1], "latest-") + ".0"
334-
version = strings.TrimPrefix(version, "v")
335-
semVersion, err := semver.Parse(version)
339+
rawVersion := strings.TrimPrefix(gomoduleParts[1], "latest-") + ".0"
340+
rawVersion = strings.TrimPrefix(rawVersion, "v")
341+
semVersion, err := semver.Parse(rawVersion)
336342
if err != nil {
337-
return "", errors.Wrapf(err, "parsing semver for %s", version)
343+
return "", errors.Wrapf(err, "parsing semver for %s", rawVersion)
338344
}
339345

340-
parsedTags, err := goproxyClient.GetVersions(ctx, gomodule)
346+
versions, err := goproxyClient.GetVersions(ctx, gomodule)
341347
if err != nil {
342348
return "", err
343349
}
344350

345-
var picked semver.Version
346-
for i, tag := range parsedTags {
347-
if !includePrereleases && len(tag.Pre) > 0 {
351+
// Search for the latest release according to semantic version ordering.
352+
// Releases with tag name that are not in semver format are ignored.
353+
versionCandidates := []semver.Version{}
354+
for _, version := range versions {
355+
if !includePrereleases && len(version.Pre) > 0 {
348356
continue
349357
}
350-
if tag.Major == semVersion.Major && tag.Minor == semVersion.Minor {
351-
picked = parsedTags[i]
358+
if version.Major != semVersion.Major || version.Minor != semVersion.Minor {
359+
continue
352360
}
361+
362+
versionCandidates = append(versionCandidates, version)
353363
}
354-
if picked.Major == 0 && picked.Minor == 0 && picked.Patch == 0 {
364+
365+
if len(versionCandidates) == 0 {
355366
return "", errors.Errorf("no suitable release available for release marker %s", releaseMarker)
356367
}
357-
return picked.String(), nil
368+
369+
// Sort parsed versions by semantic version order.
370+
sort.SliceStable(versionCandidates, func(i, j int) bool {
371+
// Prioritize pre-release versions over releases. For example v2.0.0-alpha > v1.0.0
372+
// If both are pre-releases, sort by semantic version order as usual.
373+
if len(versionCandidates[i].Pre) == 0 && len(versionCandidates[j].Pre) > 0 {
374+
return false
375+
}
376+
if len(versionCandidates[j].Pre) == 0 && len(versionCandidates[i].Pre) > 0 {
377+
return true
378+
}
379+
380+
return versionCandidates[j].LT(versionCandidates[i])
381+
})
382+
383+
// Limit the number of searchable versions by 5.
384+
versionCandidates = versionCandidates[:min(5, len(versionCandidates))]
385+
386+
for _, v := range versionCandidates {
387+
// Iterate through sorted versions and try to fetch a file from that release.
388+
// If it's completed successfully, we get the latest release.
389+
// Note: the fetched file will be cached and next time we will get it from the cache.
390+
versionString := "v" + v.String()
391+
_, err := httpGetURL(ctx, toMetadataURL(gomodule, versionString))
392+
if err != nil {
393+
if errors.Is(err, errNotFound) {
394+
// Ignore this version
395+
continue
396+
}
397+
398+
return "", err
399+
}
400+
401+
return v.String(), nil
402+
}
403+
404+
// If we reached this point, it means we didn't find any release.
405+
return "", errors.New("failed to find releases tagged with a valid semantic version number")
406+
}
407+
408+
var (
409+
retryableOperationInterval = 10 * time.Second
410+
retryableOperationTimeout = 1 * time.Minute
411+
errNotFound = errors.New("404 Not Found")
412+
)
413+
414+
func githubReleaseMetadataURL(gomodule, version string) string {
415+
// Rewrite gomodule to the github repository
416+
if strings.HasPrefix(gomodule, "k8s.io") {
417+
gomodule = strings.Replace(gomodule, "k8s.io", "github.com/kubernetes", 1)
418+
}
419+
if strings.HasPrefix(gomodule, "sigs.k8s.io") {
420+
gomodule = strings.Replace(gomodule, "sigs.k8s.io", "github.com/kubernetes-sigs", 1)
421+
}
422+
423+
return fmt.Sprintf("https://%s/releases/download/%s/metadata.yaml", gomodule, version)
424+
}
425+
426+
// httpGetURL does a GET request to the given url and returns its content.
427+
// If the responses StatusCode is 404 (StatusNotFound) it does not do a retry because
428+
// the result is not expected to change.
429+
func httpGetURL(ctx context.Context, url string) ([]byte, error) {
430+
var retryError error
431+
var content []byte
432+
_ = wait.PollUntilContextTimeout(ctx, retryableOperationInterval, retryableOperationTimeout, true, func(context.Context) (bool, error) {
433+
resp, err := http.Get(url) //nolint:gosec,noctx
434+
if err != nil {
435+
retryError = errors.Wrap(err, "error sending request")
436+
return false, nil
437+
}
438+
defer resp.Body.Close()
439+
440+
// if we get 404 there is no reason to retry
441+
if resp.StatusCode == http.StatusNotFound {
442+
retryError = errNotFound
443+
return true, nil
444+
}
445+
446+
if resp.StatusCode != http.StatusOK {
447+
retryError = errors.Errorf("error getting file, status code: %d", resp.StatusCode)
448+
return false, nil
449+
}
450+
451+
content, err = io.ReadAll(resp.Body)
452+
if err != nil {
453+
retryError = errors.Wrap(err, "error reading response body")
454+
return false, nil
455+
}
456+
457+
retryError = nil
458+
return true, nil
459+
})
460+
if retryError != nil {
461+
return nil, retryError
462+
}
463+
return content, nil
358464
}
359465

360466
// Defaults assigns default values to the object. More specifically:

test/framework/clusterctl/e2e_config_test.go

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"context"
2121
"fmt"
2222
"net/http"
23+
"net/http/httptest"
24+
"strings"
2325
"testing"
2426

2527
. "github.com/onsi/gomega"
@@ -33,14 +35,44 @@ func Test_resolveReleaseMarker(t *testing.T) {
3335
clientGoproxy := goproxy.NewClient(scheme, host)
3436
defer teardownGoproxy()
3537

36-
// setup an handler with fake releases
38+
toMetadataURL, mux, teardown := newFakeGithubReleases()
39+
defer teardown()
40+
41+
validMetadataURLs := []string{
42+
"github.com/o/r1/releases/download/v1.2.0/metadata.yaml",
43+
"github.com/o/r1/releases/download/v1.2.1-rc.0/metadata.yaml",
44+
"github.com/o/r2/releases/download/v1.2.0/metadata.yaml",
45+
"github.com/kubernetes-sigs/foo/releases/download/v1.0.0/metadata.yaml",
46+
}
47+
// Setup an handler for returning metadata.yaml.
48+
for _, url := range validMetadataURLs {
49+
mux.HandleFunc("/"+url, func(w http.ResponseWriter, r *http.Request) {
50+
goproxytest.HTTPTestMethod(t, r, "GET")
51+
fmt.Fprint(w, `somedata`)
52+
})
53+
}
54+
55+
// setup an handlers with fake releases
3756
muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) {
3857
goproxytest.HTTPTestMethod(t, r, "GET")
3958
fmt.Fprint(w, "v1.2.0\n")
4059
fmt.Fprint(w, "v1.2.1-rc.0\n")
4160
fmt.Fprint(w, "v1.3.0-rc.0\n")
4261
fmt.Fprint(w, "v1.3.0-rc.1\n")
4362
})
63+
muxGoproxy.HandleFunc("/github.com/o/r2/@v/list", func(w http.ResponseWriter, r *http.Request) {
64+
goproxytest.HTTPTestMethod(t, r, "GET")
65+
fmt.Fprint(w, "v1.2.0\n")
66+
fmt.Fprint(w, "v1.2.1\n")
67+
fmt.Fprint(w, "v1.2.2\n")
68+
})
69+
muxGoproxy.HandleFunc("/sigs.k8s.io/foo/@v/list", func(w http.ResponseWriter, r *http.Request) {
70+
goproxytest.HTTPTestMethod(t, r, "GET")
71+
fmt.Fprint(w, "v1.0.0\n")
72+
fmt.Fprint(w, "v1.0.1\n")
73+
fmt.Fprint(w, "v1.0.2\n")
74+
})
75+
4476
tests := []struct {
4577
name string
4678
releaseMarker string
@@ -71,13 +103,25 @@ func Test_resolveReleaseMarker(t *testing.T) {
71103
want: "",
72104
wantErr: true,
73105
},
106+
{
107+
name: "Get latest release with metadata",
108+
releaseMarker: "go://github.com/o/r2@latest-v1.2",
109+
want: "1.2.0",
110+
wantErr: false,
111+
},
112+
{
113+
name: "Get latest release for kubernetes-sigs project",
114+
releaseMarker: "go://sigs.k8s.io/foo@latest-v1.0",
115+
want: "1.0.0",
116+
wantErr: false,
117+
},
74118
}
75119

76120
for _, tt := range tests {
77121
t.Run(tt.name, func(t *testing.T) {
78122
g := NewWithT(t)
79123

80-
got, err := resolveReleaseMarker(context.Background(), tt.releaseMarker, clientGoproxy)
124+
got, err := resolveReleaseMarker(context.Background(), tt.releaseMarker, clientGoproxy, toMetadataURL)
81125
if tt.wantErr {
82126
g.Expect(err).To(HaveOccurred())
83127
return
@@ -159,3 +203,24 @@ func Test_E2EConfig_DeepCopy(t *testing.T) {
159203
"config1-interval": {"2m", "10s"},
160204
}))
161205
}
206+
207+
// newFakeGithubReleases sets up a test HTTP server along with a github.Client that is
208+
// configured to talk to that test server. Tests should register handlers on
209+
// mux which provide mock responses for the API method being tested.
210+
func newFakeGithubReleases() (toMetadataURL func(gomodule, version string) string, mux *http.ServeMux, teardown func()) {
211+
// mux is the HTTP request multiplexer used with the test server.
212+
mux = http.NewServeMux()
213+
214+
apiHandler := http.NewServeMux()
215+
apiHandler.Handle("/", mux)
216+
217+
// server is a test HTTP server used to provide mock API responses.
218+
server := httptest.NewServer(apiHandler)
219+
220+
toMetadataURL = func(gomodule, version string) string {
221+
url := githubReleaseMetadataURL(gomodule, version)
222+
return fmt.Sprintf("%s/%s", server.URL, strings.TrimPrefix(url, "https://"))
223+
}
224+
225+
return toMetadataURL, mux, server.Close
226+
}

0 commit comments

Comments
 (0)