diff --git a/cmd/clusterctl/client/repository/repository_github_test.go b/cmd/clusterctl/client/repository/repository_github_test.go index 95c3a3f784c6..7535ddcb9a19 100644 --- a/cmd/clusterctl/client/repository/repository_github_test.go +++ b/cmd/clusterctl/client/repository/repository_github_test.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "net/http" + "strings" "testing" "time" @@ -428,6 +429,11 @@ func Test_gitHubRepository_getLatestContractRelease(t *testing.T) { fmt.Fprint(w, `{"id":13, "tag_name": "v0.5.0", "assets": [{"id": 1, "name": "metadata.yaml"}] }`) }) + mux.HandleFunc("/repos/o/r1/releases/tags/v0.3.2", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + fmt.Fprint(w, `{"id":14, "tag_name": "v0.3.2", "assets": [{"id": 2, "name": "metadata.yaml"}] }`) + }) + // Setup a handler for returning a fake release metadata file. mux.HandleFunc("/repos/o/r1/releases/assets/1", func(w http.ResponseWriter, r *http.Request) { goproxytest.HTTPTestMethod(t, r, "GET") @@ -436,6 +442,13 @@ 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") }) + mux.HandleFunc("/repos/o/r1/releases/assets/2", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=metadata.yaml") + 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") + }) + scheme, host, muxGoproxy, teardownGoproxy := goproxytest.NewFakeGoproxy() clientGoproxy := goproxy.NewClient(scheme, host) @@ -534,6 +547,9 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) { clientGoproxy := goproxy.NewClient(scheme, host) defer teardownGoproxy() + client, mux, teardown := test.NewFakeGitHub() + defer teardown() + // Setup a handler for returning 4 fake releases. muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) { goproxytest.HTTPTestMethod(t, r, "GET") @@ -543,9 +559,13 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) { fmt.Fprint(w, "foo\n") // no semantic version tag }) // And also expose a release for them - muxGoproxy.HandleFunc("/api.github.com/repos/o/r1/releases/tags/v0.4.2", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/repos/o/r1/releases/tags/v0.4.2", func(w http.ResponseWriter, r *http.Request) { goproxytest.HTTPTestMethod(t, r, "GET") - fmt.Fprint(w, "{}\n") + fmt.Fprint(w, `{"id":13, "tag_name": "v0.4.2", "assets": [{"id": 1, "name": "metadata.yaml"}] }`) + }) + mux.HandleFunc("/repos/o/r3/releases/tags/v0.1.0-alpha.2", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + fmt.Fprint(w, `{"id":14, "tag_name": "v0.1.0-alpha.2", "assets": [{"id": 2, "name": "metadata.yaml"}] }`) }) // Setup a handler for returning no releases. @@ -562,6 +582,21 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) { fmt.Fprint(w, "v0.1.0-alpha.2\n") }) + // Setup a handler for returning a fake release metadata file. + mux.HandleFunc("/repos/o/r1/releases/assets/1", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=metadata.yaml") + 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") + }) + + mux.HandleFunc("/repos/o/r3/releases/assets/2", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=metadata.yaml") + 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") + }) + configVariablesClient := test.NewFakeVariableClient() type field struct { @@ -606,7 +641,7 @@ func Test_gitHubRepository_getLatestRelease(t *testing.T) { resetCaches() - gRepo, err := NewGitHubRepository(ctx, tt.field.providerConfig, configVariablesClient, injectGoproxyClient(clientGoproxy)) + gRepo, err := NewGitHubRepository(ctx, tt.field.providerConfig, configVariablesClient, injectGoproxyClient(clientGoproxy), injectGithubClient(client)) g.Expect(err).ToNot(HaveOccurred()) got, err := latestRelease(ctx, gRepo) @@ -628,6 +663,9 @@ func Test_gitHubRepository_getLatestPatchRelease(t *testing.T) { clientGoproxy := goproxy.NewClient(scheme, host) defer teardownGoproxy() + client, mux, teardown := test.NewFakeGitHub() + defer teardown() + // Setup a handler for returning 4 fake releases. muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) { goproxytest.HTTPTestMethod(t, r, "GET") @@ -636,6 +674,30 @@ func Test_gitHubRepository_getLatestPatchRelease(t *testing.T) { fmt.Fprint(w, "v1.3.2\n") }) + // Setup a handler for returning a fake release. + mux.HandleFunc("/repos/o/r1/releases/tags/v0.4.0", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + fmt.Fprint(w, `{"id":13, "tag_name": "v0.4.0", "assets": [{"id": 1, "name": "metadata.yaml"}] }`) + }) + + mux.HandleFunc("/repos/o/r1/releases/tags/v0.3.2", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + fmt.Fprint(w, `{"id":14, "tag_name": "v0.3.2", "assets": [{"id": 1, "name": "metadata.yaml"}] }`) + }) + + mux.HandleFunc("/repos/o/r1/releases/tags/v1.3.2", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + fmt.Fprint(w, `{"id":15, "tag_name": "v1.3.2", "assets": [{"id": 1, "name": "metadata.yaml"}] }`) + }) + + // Setup a handler for returning a fake release metadata file. + mux.HandleFunc("/repos/o/r1/releases/assets/1", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=metadata.yaml") + 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") + }) + major0 := uint(0) minor3 := uint(3) minor4 := uint(4) @@ -692,7 +754,7 @@ func Test_gitHubRepository_getLatestPatchRelease(t *testing.T) { resetCaches() - gRepo, err := NewGitHubRepository(ctx, tt.field.providerConfig, configVariablesClient, injectGoproxyClient(clientGoproxy)) + gRepo, err := NewGitHubRepository(ctx, tt.field.providerConfig, configVariablesClient, injectGoproxyClient(clientGoproxy), injectGithubClient(client)) g.Expect(err).ToNot(HaveOccurred()) got, err := latestPatchRelease(ctx, gRepo, tt.major, tt.minor) @@ -913,3 +975,136 @@ func resetCaches() { cacheReleases = map[string]*github.RepositoryRelease{} cacheFiles = map[string][]byte{} } + +func Test_gitHubRepository_releaseNotFound(t *testing.T) { + retryableOperationInterval = 200 * time.Millisecond + retryableOperationTimeout = 1 * time.Second + + tests := []struct { + name string + releaseTags []string + ghReleases []string + want string + wantErr bool + }{ + { + name: "One release", + releaseTags: []string{"v0.4.2"}, + ghReleases: []string{"v0.4.2"}, + want: "v0.4.2", + wantErr: false, + }, + { + name: "Latest tag without a release", + releaseTags: []string{"v0.5.0", "v0.4.2"}, + ghReleases: []string{"v0.4.2"}, + want: "v0.4.2", + wantErr: false, + }, + { + name: "Two tags without releases", + releaseTags: []string{"v0.6.0", "v0.5.0", "v0.4.2"}, + ghReleases: []string{"v0.4.2"}, + want: "v0.4.2", + wantErr: false, + }, + { + name: "Five tags without releases", + releaseTags: []string{"v0.9.0", "v0.8.0", "v0.7.0", "v0.6.0", "v0.5.0", "v0.4.2"}, + ghReleases: []string{"v0.4.2"}, + wantErr: true, + }, + { + name: "Pre-releases have lower priority", + releaseTags: []string{"v0.7.0-alpha", "v0.6.0-alpha", "v0.5.0-alpha", "v0.4.2"}, + ghReleases: []string{"v0.4.2"}, + want: "v0.4.2", + wantErr: false, + }, + { + name: "Two Github releases", + releaseTags: []string{"v0.7.0", "v0.6.0", "v0.5.0", "v0.4.2"}, + ghReleases: []string{"v0.5.0", "v0.4.2"}, + want: "v0.5.0", + wantErr: false, + }, + { + name: "Github release and prerelease", + releaseTags: []string{"v0.6.0", "v0.5.0-alpha", "v0.4.2"}, + ghReleases: []string{"v0.5.0-alpha", "v0.4.2"}, + want: "v0.4.2", + wantErr: false, + }, + { + name: "No Github releases", + releaseTags: []string{"v0.6.0", "v0.5.0", "v0.4.2"}, + ghReleases: []string{}, + wantErr: true, + }, + { + name: "Pre-releases only", + releaseTags: []string{"v0.6.0-alpha", "v0.5.0-alpha", "v0.4.2-alpha"}, + ghReleases: []string{"v0.5.0-alpha"}, + want: "v0.5.0-alpha", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + ctx := context.Background() + + configVariablesClient := test.NewFakeVariableClient() + + resetCaches() + + client, mux, teardown := test.NewFakeGitHub() + defer teardown() + + providerConfig := config.NewProvider("test", "https://github.com/o/r1/releases/v0.4.1/file.yaml", clusterctlv1.CoreProviderType) + + scheme, host, muxGoproxy, teardownGoproxy := goproxytest.NewFakeGoproxy() + clientGoproxy := goproxy.NewClient(scheme, host) + + defer teardownGoproxy() + + // First, register tags within goproxy. + muxGoproxy.HandleFunc("/github.com/o/r1/@v/list", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + for _, release := range tt.releaseTags { + fmt.Fprint(w, release+"\n") + } + }) + + // Second, register releases in GitHub. + for _, release := range tt.ghReleases { + mux.HandleFunc(fmt.Sprintf("/repos/o/r1/releases/tags/%s", release), func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + parts := strings.Split(r.RequestURI, "/") + version := parts[len(parts)-1] + fmt.Fprintf(w, "{\"id\":13, \"tag_name\": %q, \"assets\": [{\"id\": 1, \"name\": \"metadata.yaml\"}] }", version) + }) + } + + // Third, setup a handler for returning a fake release metadata file. + mux.HandleFunc("/repos/o/r1/releases/assets/1", func(w http.ResponseWriter, r *http.Request) { + goproxytest.HTTPTestMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=metadata.yaml") + 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") + }) + + gRepo, err := NewGitHubRepository(ctx, providerConfig, configVariablesClient, injectGithubClient(client), injectGoproxyClient(clientGoproxy)) + g.Expect(err).ToNot(HaveOccurred()) + + got, err := latestRelease(ctx, gRepo) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} diff --git a/cmd/clusterctl/client/repository/repository_local_test.go b/cmd/clusterctl/client/repository/repository_local_test.go index 89d3040a9b64..0c6234ce9d05 100644 --- a/cmd/clusterctl/client/repository/repository_local_test.go +++ b/cmd/clusterctl/client/repository/repository_local_test.go @@ -29,6 +29,10 @@ import ( "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test" ) +const ( + metadataContents = "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" +) + func Test_localRepository_newLocalRepository(t *testing.T) { type fields struct { provider config.Provider @@ -157,6 +161,7 @@ func Test_localRepository_newLocalRepository_Latest(t *testing.T) { // Create several release directories createLocalTestProviderFile(t, tmpDir, "bootstrap-foo/v1.0.0/bootstrap-components.yaml", "foo: bar") createLocalTestProviderFile(t, tmpDir, "bootstrap-foo/v1.0.1/bootstrap-components.yaml", "foo: bar") + createLocalTestProviderFile(t, tmpDir, "bootstrap-foo/v1.0.1/metadata.yaml", metadataContents) createLocalTestProviderFile(t, tmpDir, "bootstrap-foo/v2.0.0-alpha.0/bootstrap-components.yaml", "foo: bar") createLocalTestProviderFile(t, tmpDir, "bootstrap-foo/Foo.Bar/bootstrap-components.yaml", "foo: bar") createLocalTestProviderFile(t, tmpDir, "bootstrap-foo/foo.file", "foo: bar") @@ -185,8 +190,10 @@ func Test_localRepository_GetFile(t *testing.T) { p1 := config.NewProvider("foo", dst1, clusterctlv1.BootstrapProviderType) // Provider 2: URL is for the latest release + createLocalTestProviderFile(t, tmpDir, "bootstrap-baz/v1.0.0-alpha.0/metadata.yaml", metadataContents) createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v1.0.0/bootstrap-components.yaml", "version: v1.0.0") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v1.0.1/bootstrap-components.yaml", "version: v1.0.1") + createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v1.0.1/metadata.yaml", metadataContents) createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v2.0.0-alpha.0/bootstrap-components.yaml", "version: v2.0.0-alpha.0") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/Foo.Bar/bootstrap-components.yaml", "version: Foo.Bar") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/foo.file", "foo: bar") @@ -323,6 +330,7 @@ func Test_localRepository_GetVersions(t *testing.T) { createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v1.0.0/bootstrap-components.yaml", "version: v1.0.0") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v1.0.1/bootstrap-components.yaml", "version: v1.0.1") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v2.0.1/bootstrap-components.yaml", "version: v2.0.1") + createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v2.0.2+exp.sha.5114f85/metadata.yaml", metadataContents) createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v2.0.2+exp.sha.5114f85/bootstrap-components.yaml", "version: v2.0.2+exp.sha.5114f85") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/v2.0.3-alpha/bootstrap-components.yaml", "version: v2.0.3-alpha") createLocalTestProviderFile(t, tmpDir, "bootstrap-bar/Foo.Bar/bootstrap-components.yaml", "version: Foo.Bar") diff --git a/cmd/clusterctl/client/repository/repository_versions.go b/cmd/clusterctl/client/repository/repository_versions.go index 3f2c5497d2ce..599fb942f457 100644 --- a/cmd/clusterctl/client/repository/repository_versions.go +++ b/cmd/clusterctl/client/repository/repository_versions.go @@ -18,6 +18,7 @@ package repository import ( "context" + "sort" "github.com/pkg/errors" "k8s.io/apimachinery/pkg/runtime" @@ -91,11 +92,7 @@ func latestPatchRelease(ctx context.Context, repo Repository, major, minor *uint // Search for the latest release according to semantic version ordering. // Releases with tag name that are not in semver format are ignored. - var latestTag string - var latestPrereleaseTag string - - var latestReleaseVersion *version.Version - var latestPrereleaseVersion *version.Version + versionCandidates := []*version.Version{} for _, v := range versions { sv, err := version.ParseSemantic(v) @@ -109,28 +106,48 @@ func latestPatchRelease(ctx context.Context, repo Repository, major, minor *uint continue } - // track prereleases separately - if sv.PreRelease() != "" { - if latestPrereleaseVersion == nil || latestPrereleaseVersion.LessThan(sv) { - latestPrereleaseTag = v - latestPrereleaseVersion = sv - } - continue - } + versionCandidates = append(versionCandidates, sv) + } - if latestReleaseVersion == nil || latestReleaseVersion.LessThan(sv) { - latestTag = v - latestReleaseVersion = sv - } + if len(versionCandidates) == 0 { + return "", errors.New("failed to find releases tagged with a valid semantic version number") } - // Fall back to returning latest prereleases if no release has been cut or bail if it's also empty - if latestTag == "" { - if latestPrereleaseTag == "" { - return "", errors.New("failed to find releases tagged with a valid semantic version number") + // Sort parsed versions by semantic version order. + sort.SliceStable(versionCandidates, func(i, j int) bool { + // Prioritize release versions over pre-releases. For example v1.0.0 > v2.0.0-alpha + // If both are pre-releases, sort by semantic version order as usual. + if versionCandidates[j].PreRelease() == "" && versionCandidates[i].PreRelease() != "" { + return false + } + if versionCandidates[i].PreRelease() == "" && versionCandidates[j].PreRelease() != "" { + return true + } + + return versionCandidates[j].LessThan(versionCandidates[i]) + }) + + // Limit the number of searchable versions by 5. + versionCandidates = versionCandidates[:min(5, len(versionCandidates))] + + for _, v := range versionCandidates { + // Iterate through sorted versions and try to fetch a file from that release. + // If it's completed successfully, we get the latest release. + // Note: the fetched file will be cached and next time we will get it from the cache. + versionString := "v" + v.String() + _, err := repo.GetFile(ctx, versionString, metadataFile) + if err != nil { + if errors.Is(err, errNotFound) { + // Ignore this version + continue + } + + return "", err } - return latestPrereleaseTag, nil + return versionString, nil } - return latestTag, nil + + // If we reached this point, it means we didn't find any release. + return "", errors.New("failed to find releases tagged with a valid semantic version number") }