diff --git a/pkg/plugins/resources/temurin/api.go b/pkg/plugins/resources/temurin/api.go index 9735106b55..da706bcfa1 100644 --- a/pkg/plugins/resources/temurin/api.go +++ b/pkg/plugins/resources/temurin/api.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "slices" "github.com/sirupsen/logrus" "github.com/updatecli/updatecli/pkg/core/httpclient" @@ -23,8 +24,15 @@ const signaturesEndpoint = "/signature/version" const parseVersionEndpoint = "/version" const releaseNamesEndpoint = "/info/release_names" +func (t Temurin) baseURL() string { + if t.apiURL != "" { + return t.apiURL + } + return temurinApiUrl +} + func (t Temurin) apiPerformHttpReq(endpoint string, webClient httpclient.HTTPClient) (body []byte, locationHeader string, err error) { - url := temurinApiUrl + endpoint + url := t.baseURL() + endpoint req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -69,18 +77,34 @@ func (t Temurin) apiGetRedirectLocation(endpoint string) (redirectLocation strin return redirectLocation, err } -func (t Temurin) apiGetLastFeatureRelease() (result int, err error) { - apiInfoReleases, err := t.apiGetInfoReleases() +func (t Temurin) apiGetLastFeatureRelease() (result int, fallbacks []int, err error) { + infoReleases, err := t.apiGetInfoReleases() if err != nil { - return result, err + return result, nil, err } - result = apiInfoReleases.MostRecentLTS + var candidates []int if t.spec.ReleaseLine == "feature" { - result = apiInfoReleases.MostRecentFeatureRelease + result = infoReleases.MostRecentFeatureRelease + candidates = slices.Clone(infoReleases.AvailableReleases) + } else { + result = infoReleases.MostRecentLTS + candidates = slices.Clone(infoReleases.AvailableLTSReleases) } + slices.Sort(candidates) - return result, err + if len(candidates) == 0 { + logrus.Debugf("[temurin] API returned no available releases for release line %q", t.spec.ReleaseLine) + } + + // Build fallbacks: all candidates except the primary, highest version first. + for i := len(candidates) - 1; i >= 0; i-- { + if candidates[i] != result { + fallbacks = append(fallbacks, candidates[i]) + } + } + + return result, fallbacks, nil } func (t Temurin) apiGetInfoReleases() (result *apiInfoReleases, err error) { @@ -151,17 +175,51 @@ func (t Temurin) apiParseVersion(version string) (result parsedVersion, err erro return result, nil } -func (t Temurin) apiGetReleaseNames() (result []string, err error) { - var versionRange string +// apiQueryReleaseNamesForRange queries the release_names endpoint for an arbitrary semver range string. +func (t Temurin) apiQueryReleaseNamesForRange(versionRange string) ([]string, error) { + apiEndpoint := fmt.Sprintf( + "%s?heap_size=normal&image_type=%s&page=0&page_size=10&project=%s&release_type=%s&architecture=%s&os=%s&semver=true&sort_method=DEFAULT&sort_order=DESC&vendor=eclipse&version=%s", + releaseNamesEndpoint, + url.QueryEscape(t.spec.ImageType), + url.QueryEscape(t.spec.Project), + url.QueryEscape(t.spec.ReleaseType), + url.QueryEscape(t.spec.Architecture), + url.QueryEscape(t.spec.OperatingSystem), + url.QueryEscape(versionRange), + ) + + logrus.Debugf("[temurin] using API endpoint %q", apiEndpoint) + + body, err := t.apiGetBody(apiEndpoint) + if err != nil { + return nil, err + } + + var apiResult releaseInformation + if err := json.Unmarshal(body, &apiResult); err != nil { + logrus.Debugf("[temurin] Failed decoding the response: %q\n", err) + return nil, fmt.Errorf("[temurin] No release found matching provided criteria. Use '--debug' to get details") + } + + return apiResult.Releases, nil +} + +// apiQueryReleaseNames queries the release_names endpoint for a specific feature version. +// The version range covers all patch releases within that major version: (N.0.0, N+1.0.0]. +func (t Temurin) apiQueryReleaseNames(featureVersion int) ([]string, error) { + versionRange := fmt.Sprintf("(%d.0.0, %d.0.0]", featureVersion, featureVersion+1) + return t.apiQueryReleaseNamesForRange(versionRange) +} - // If user specified a custom version, we have to normalize and validate it +func (t Temurin) apiGetReleaseNames() (result []string, err error) { + // If user specified a custom version, normalize and validate it first. if t.spec.SpecificVersion != "" { parsedVersion, err := t.apiParseVersion(t.spec.SpecificVersion) if err != nil { return []string{}, err } - versionRange = fmt.Sprintf("(%d.%d.%d, %d.%d.%d]", + versionRange := fmt.Sprintf("(%d.%d.%d, %d.%d.%d]", parsedVersion.Major, parsedVersion.Minor, parsedVersion.Security, @@ -170,47 +228,53 @@ func (t Temurin) apiGetReleaseNames() (result []string, err error) { parsedVersion.Security+1, ) - } else { - featureVersion := t.spec.FeatureVersion - if featureVersion == 0 { - featureVersion, err = t.apiGetLastFeatureRelease() - if err != nil { - return []string{}, err - } - } - - versionRange = fmt.Sprintf("(%d.0.0, %d.0.0]", featureVersion, featureVersion+1) + return t.apiQueryReleaseNamesForRange(versionRange) } - apiEndpoint := fmt.Sprintf( - "%s?heap_size=normal&image_type=%s&page=0&page_size=10&project=%s&release_type=%s&architecture=%s&os=%s&semver=true&sort_method=DEFAULT&sort_order=DESC&vendor=eclipse&version=%s", - releaseNamesEndpoint, - t.spec.ImageType, - t.spec.Project, - t.spec.ReleaseType, - t.spec.Architecture, - t.spec.OperatingSystem, - // Mandatory URL encoding otherwise empty responses or HTTP errors - url.QueryEscape(versionRange), - ) + featureVersion := t.spec.FeatureVersion + var fallbacks []int + if featureVersion == 0 { + featureVersion, fallbacks, err = t.apiGetLastFeatureRelease() + if err != nil { + return []string{}, err + } + } - logrus.Debugf("[temurin] using API endpoint %q", apiEndpoint) + // Try the primary feature version, then fall back to older releases if the + // API returns no results (e.g. a newly announced major with no GA builds yet). + const maxAttempts = 3 + candidates := append([]int{featureVersion}, fallbacks...) + if len(candidates) > maxAttempts { + candidates = candidates[:maxAttempts] + } - body, err := t.apiGetBody(apiEndpoint) - if err != nil { - logrus.Errorf("something went wrong while getting Temurin API latest release information %q.", err) - return result, err + var firstErr error + for i, version := range candidates { + releases, queryErr := t.apiQueryReleaseNames(version) + if queryErr != nil { + if firstErr == nil { + firstErr = queryErr + logrus.Debugf("[temurin] feature version %d returned an error, trying fallback: %v", version, queryErr) + } + logrus.Debugf("[temurin] falling back from feature version %d after error: %v", version, queryErr) + continue + } + if len(releases) == 0 { + logrus.Debugf("[temurin] no releases found for feature version %d, trying fallback", version) + continue + } + if i > 0 { + logrus.Debugf("[temurin] using fallback feature version %d", version) + } + return releases, nil } - var apiResult releaseInformation - err = json.Unmarshal(body, &apiResult) - if err != nil { - logrus.Debugf("[temurin] Failed decoding the response: %q\n", err) - return result, fmt.Errorf("[temurin] No release found matching provided criteria. Use '--debug' to get details") + if firstErr != nil { + return []string{}, firstErr } - // Return only the most recent, e.g. the first one (sort is DESC in the URL) - return apiResult.Releases, nil + logrus.Debugf("[temurin] exhausted all %d candidate feature versions with no matching releases", len(candidates)) + return []string{}, nil } func (t Temurin) apiGetInstallerUrl(releaseName string) (result string, err error) { diff --git a/pkg/plugins/resources/temurin/api_test.go b/pkg/plugins/resources/temurin/api_test.go new file mode 100644 index 0000000000..76046f19ea --- /dev/null +++ b/pkg/plugins/resources/temurin/api_test.go @@ -0,0 +1,395 @@ +package temurin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestApiGetReleaseNames_FallbackOnFeature verifies that when the most recent +// feature release (26) has no GA builds indexed, the plugin falls back to the +// next available release (25) and returns its release names. +func TestApiGetReleaseNames_FallbackOnFeature(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc(availableReleasesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(apiInfoReleases{ + MostRecentFeatureRelease: 26, + MostRecentLTS: 25, + AvailableReleases: []int{8, 11, 17, 21, 25, 26}, + AvailableLTSReleases: []int{8, 11, 17, 21, 25}, + }) + }) + + mux.HandleFunc(releaseNamesEndpoint, func(w http.ResponseWriter, r *http.Request) { + // The version range for major N is "(N.0.0, N+1.0.0]", so we match on + // the range start to avoid false positives (e.g. the range for 25 + // contains "26" as the upper bound). + versionParam := r.URL.Query().Get("version") + switch { + case strings.HasPrefix(versionParam, "(26."): + // Feature release 26 has no GA builds yet. + http.Error(w, "not found", http.StatusNotFound) + case strings.HasPrefix(versionParam, "(25."): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releaseInformation{ + Releases: []string{"jdk-25.0.2+10"}, + }) + default: + http.Error(w, "not found", http.StatusNotFound) + } + }) + + mux.HandleFunc(architecturesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"x64"}) + }) + + mux.HandleFunc(osEndpoints, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"linux"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + sut := &Temurin{ + spec: Spec{ + ReleaseLine: "feature", + ReleaseType: "ga", + Architecture: "x64", + OperatingSystem: "linux", + ImageType: "jdk", + Project: "jdk", + Result: "version", + }, + apiURL: server.URL, + } + sut.apiWebClient = server.Client() + sut.apiWebRedirectionClient = server.Client() + + releases, err := sut.apiGetReleaseNames() + + require.NoError(t, err) + assert.Equal(t, []string{"jdk-25.0.2+10"}, releases) +} + +// TestApiGetReleaseNames_FallbackOnLTS verifies that when the most recent LTS +// release (25) has no GA builds indexed, the plugin falls back to the next +// available LTS (21) and returns its release names. +func TestApiGetReleaseNames_FallbackOnLTS(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc(availableReleasesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(apiInfoReleases{ + MostRecentLTS: 25, + MostRecentFeatureRelease: 26, + AvailableReleases: []int{8, 11, 17, 21, 25, 26}, + AvailableLTSReleases: []int{8, 11, 17, 21, 25}, + }) + }) + + mux.HandleFunc(releaseNamesEndpoint, func(w http.ResponseWriter, r *http.Request) { + versionParam := r.URL.Query().Get("version") + switch { + case strings.HasPrefix(versionParam, "(25."): + http.Error(w, "not found", http.StatusNotFound) + case strings.HasPrefix(versionParam, "(21."): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releaseInformation{ + Releases: []string{"jdk-21.0.6+7"}, + }) + default: + http.Error(w, "not found", http.StatusNotFound) + } + }) + + mux.HandleFunc(architecturesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"x64"}) + }) + + mux.HandleFunc(osEndpoints, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"linux"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + sut := &Temurin{ + spec: Spec{ + ReleaseLine: "", + ReleaseType: "ga", + Architecture: "x64", + OperatingSystem: "linux", + ImageType: "jdk", + Project: "jdk", + Result: "version", + }, + apiURL: server.URL, + } + sut.apiWebClient = server.Client() + sut.apiWebRedirectionClient = server.Client() + + releases, err := sut.apiGetReleaseNames() + + require.NoError(t, err) + assert.Equal(t, []string{"jdk-21.0.6+7"}, releases) +} + +// TestApiGetReleaseNames_AllCandidatesFail verifies that when every candidate +// version returns an API error the first error is propagated to the caller. +func TestApiGetReleaseNames_AllCandidatesFail(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc(availableReleasesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(apiInfoReleases{ + MostRecentFeatureRelease: 26, + MostRecentLTS: 25, + AvailableReleases: []int{8, 11, 17, 21, 25, 26}, + AvailableLTSReleases: []int{8, 11, 17, 21, 25}, + }) + }) + + mux.HandleFunc(releaseNamesEndpoint, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + }) + + mux.HandleFunc(architecturesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"x64"}) + }) + + mux.HandleFunc(osEndpoints, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"linux"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + sut := &Temurin{ + spec: Spec{ + ReleaseLine: "feature", + ReleaseType: "ga", + Architecture: "x64", + OperatingSystem: "linux", + ImageType: "jdk", + Project: "jdk", + Result: "version", + }, + apiURL: server.URL, + } + sut.apiWebClient = server.Client() + sut.apiWebRedirectionClient = server.Client() + + releases, err := sut.apiGetReleaseNames() + + require.Error(t, err) + assert.Contains(t, err.Error(), "500") + assert.Empty(t, releases) +} + +// TestApiGetReleaseNames_ExplicitFeatureVersion verifies that when FeatureVersion +// is explicitly set in Spec the available-releases endpoint is never consulted and +// only that single version is queried. +func TestApiGetReleaseNames_ExplicitFeatureVersion(t *testing.T) { + mux := http.NewServeMux() + + // If this endpoint is hit the test should still pass, but we register a 500 + // so that any accidental call surfaces as a test failure. + mux.HandleFunc(availableReleasesEndpoint, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "should not be called", http.StatusInternalServerError) + }) + + mux.HandleFunc(releaseNamesEndpoint, func(w http.ResponseWriter, r *http.Request) { + versionParam := r.URL.Query().Get("version") + switch { + case strings.HasPrefix(versionParam, "(21."): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releaseInformation{ + Releases: []string{"jdk-21.0.6+7"}, + }) + default: + http.Error(w, "not found", http.StatusNotFound) + } + }) + + mux.HandleFunc(architecturesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"x64"}) + }) + + mux.HandleFunc(osEndpoints, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"linux"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + sut := &Temurin{ + spec: Spec{ + FeatureVersion: 21, + ReleaseLine: "feature", + ReleaseType: "ga", + Architecture: "x64", + OperatingSystem: "linux", + ImageType: "jdk", + Project: "jdk", + Result: "version", + }, + apiURL: server.URL, + } + sut.apiWebClient = server.Client() + sut.apiWebRedirectionClient = server.Client() + + releases, err := sut.apiGetReleaseNames() + + require.NoError(t, err) + assert.Equal(t, []string{"jdk-21.0.6+7"}, releases) +} + +// TestApiGetReleaseNames_EmptyReleasesTriggersNextCandidate verifies that an +// HTTP 200 response carrying an empty releases list is treated as "no results" +// and causes the plugin to fall back to the next candidate version. +func TestApiGetReleaseNames_EmptyReleasesTriggersNextCandidate(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc(availableReleasesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(apiInfoReleases{ + MostRecentFeatureRelease: 26, + MostRecentLTS: 25, + AvailableReleases: []int{8, 11, 17, 21, 25, 26}, + AvailableLTSReleases: []int{8, 11, 17, 21, 25}, + }) + }) + + mux.HandleFunc(releaseNamesEndpoint, func(w http.ResponseWriter, r *http.Request) { + versionParam := r.URL.Query().Get("version") + switch { + case strings.HasPrefix(versionParam, "(26."): + // 200 with empty releases — should trigger fallback. + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releaseInformation{Releases: []string{}}) + case strings.HasPrefix(versionParam, "(25."): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releaseInformation{ + Releases: []string{"jdk-25.0.2+10"}, + }) + default: + http.Error(w, "not found", http.StatusNotFound) + } + }) + + mux.HandleFunc(architecturesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"x64"}) + }) + + mux.HandleFunc(osEndpoints, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"linux"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + sut := &Temurin{ + spec: Spec{ + ReleaseLine: "feature", + ReleaseType: "ga", + Architecture: "x64", + OperatingSystem: "linux", + ImageType: "jdk", + Project: "jdk", + Result: "version", + }, + apiURL: server.URL, + } + sut.apiWebClient = server.Client() + sut.apiWebRedirectionClient = server.Client() + + releases, err := sut.apiGetReleaseNames() + + require.NoError(t, err) + assert.Equal(t, []string{"jdk-25.0.2+10"}, releases) +} + +// TestApiGetReleaseNames_DescendingAvailableReleases verifies that the fallback +// ordering is correct even when AvailableReleases is returned in descending order +// by the API. The plugin should still attempt the highest version after the +// primary and not start from the lowest. +func TestApiGetReleaseNames_DescendingAvailableReleases(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc(availableReleasesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Deliberately descending — the sort fix must normalise this. + _ = json.NewEncoder(w).Encode(apiInfoReleases{ + MostRecentFeatureRelease: 26, + MostRecentLTS: 25, + AvailableReleases: []int{26, 25, 21, 17, 11, 8}, + AvailableLTSReleases: []int{25, 21, 17, 11, 8}, + }) + }) + + mux.HandleFunc(releaseNamesEndpoint, func(w http.ResponseWriter, r *http.Request) { + versionParam := r.URL.Query().Get("version") + switch { + case strings.HasPrefix(versionParam, "(26."): + http.Error(w, "not found", http.StatusNotFound) + case strings.HasPrefix(versionParam, "(25."): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releaseInformation{ + Releases: []string{"jdk-25.0.2+10"}, + }) + default: + http.Error(w, "not found", http.StatusNotFound) + } + }) + + mux.HandleFunc(architecturesEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"x64"}) + }) + + mux.HandleFunc(osEndpoints, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"linux"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + sut := &Temurin{ + spec: Spec{ + ReleaseLine: "feature", + ReleaseType: "ga", + Architecture: "x64", + OperatingSystem: "linux", + ImageType: "jdk", + Project: "jdk", + Result: "version", + }, + apiURL: server.URL, + } + sut.apiWebClient = server.Client() + sut.apiWebRedirectionClient = server.Client() + + releases, err := sut.apiGetReleaseNames() + + // The fallback must have tried 25 (next highest after 26), not 8. + require.NoError(t, err) + assert.Equal(t, []string{"jdk-25.0.2+10"}, releases) +} diff --git a/pkg/plugins/resources/temurin/main.go b/pkg/plugins/resources/temurin/main.go index 6038100426..a0345f9e05 100644 --- a/pkg/plugins/resources/temurin/main.go +++ b/pkg/plugins/resources/temurin/main.go @@ -19,6 +19,8 @@ type Temurin struct { apiWebClient httpclient.HTTPClient apiWebRedirectionClient httpclient.HTTPClient foundVersion string + // apiURL overrides the default temurinApiUrl. Used in tests to point at a mock server. + apiURL string } /*