From af9c9ebf238727f1474fc5de0356b5ca989c0c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFs=20Postula?= Date: Tue, 24 Mar 2026 11:07:26 +0100 Subject: [PATCH] When the Adoptium API advertises a MostRecentFeatureRelease (e.g. 26) that has no GA builds indexed yet, the plugin now iterates descending through AvailableReleases until it finds one with results, instead of failing outright with HTTP 404. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #8073 Signed-off-by: Loïs Postula --- pkg/plugins/resources/temurin/api.go | 152 ++++++--- pkg/plugins/resources/temurin/api_test.go | 395 ++++++++++++++++++++++ pkg/plugins/resources/temurin/main.go | 2 + 3 files changed, 505 insertions(+), 44 deletions(-) create mode 100644 pkg/plugins/resources/temurin/api_test.go 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 } /*