diff --git a/tools/moduleversions/Taskfile.dist.yaml b/tools/moduleversions/Taskfile.dist.yaml index de1f9c7f0e..53b524cacd 100644 --- a/tools/moduleversions/Taskfile.dist.yaml +++ b/tools/moduleversions/Taskfile.dist.yaml @@ -13,7 +13,7 @@ tasks: - go mod download check:releases: - desc: "Check version on release site (https://releases.deckhouse.io/ee/modules/virtualization)" + desc: "Check version on release site (https://releases.deckhouse.io/modules/virtualization)" vars: COUNT: "{{ .COUNT | default 5 }}" deps: diff --git a/tools/moduleversions/go.mod b/tools/moduleversions/go.mod index 489d866e45..44e2617d3a 100644 --- a/tools/moduleversions/go.mod +++ b/tools/moduleversions/go.mod @@ -1,13 +1,12 @@ module moduleversions -go 1.25.6 +go 1.25.10 require ( github.com/PuerkitoBio/goquery v1.10.3 github.com/blang/semver/v4 v4.0.0 github.com/google/go-containerregistry v0.21.1 github.com/spf13/cobra v1.10.2 - golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/tools/moduleversions/go.sum b/tools/moduleversions/go.sum index 53946ceca2..002c776eb6 100644 --- a/tools/moduleversions/go.sum +++ b/tools/moduleversions/go.sum @@ -113,8 +113,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/tools/moduleversions/internal/releases/checker.go b/tools/moduleversions/internal/releases/checker.go index 1b1dd25d90..a6ba0ad716 100644 --- a/tools/moduleversions/internal/releases/checker.go +++ b/tools/moduleversions/internal/releases/checker.go @@ -19,12 +19,11 @@ package releases import ( "fmt" "net/http" + "net/url" "strings" "time" "github.com/PuerkitoBio/goquery" - "golang.org/x/text/cases" - "golang.org/x/text/language" "moduleversions/internal/version" ) @@ -54,13 +53,11 @@ func (v ModuleVersionInfo) String() string { const ( httpTimeout = 5 * time.Second expectedCellsCount = 6 - // minURLPartsCount is the minimum number of parts expected in a URL after splitting by "/" - // For example: "https://releases.deckhouse.io/ee" -> ["https:", "", "releases.deckhouse.io", "ee"] = 4 parts - minURLPartsCount = 4 ) var channelMap = map[string]int{ - // Channel column indices in the HTML table on releases.deckhouse.io + // Channel column indices in the HTML table on releases.deckhouse.io/modules/{module}. + // The first column contains the edition name. "alpha": 1, "beta": 2, "early-access": 3, @@ -68,102 +65,75 @@ var channelMap = map[string]int{ "rock-solid": 5, } -// VerifyVersionInEdition checks if the specified version exists for the given channel -// on the releases.deckhouse.io page for a specific edition. -func VerifyVersionInEdition(editionURL, channel, expectedVersion, moduleName string) (bool, error) { +// VerifyVersionOnModulePage checks if the specified version exists for the given channel +// on the releases.deckhouse.io module page. +func VerifyVersionOnModulePage(moduleURL, channel, expectedVersion, moduleName string) (bool, *ModuleVersionInfo, error) { client := &http.Client{ Timeout: httpTimeout, } - resp, err := client.Get(editionURL) + resp, err := client.Get(moduleURL) if err != nil { - return false, fmt.Errorf("failed to fetch edition URL %s: %w", editionURL, err) + return false, nil, fmt.Errorf("failed to fetch module URL %s: %w", moduleURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("unexpected status code %d for URL %s", resp.StatusCode, editionURL) + return false, nil, fmt.Errorf("unexpected status code %d for URL %s", resp.StatusCode, moduleURL) } doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { - return false, fmt.Errorf("failed to parse HTML from %s: %w", editionURL, err) + return false, nil, fmt.Errorf("failed to parse HTML from %s: %w", moduleURL, err) } - var ( - index int - webVersion string - ) - index, ok := channelMap[channel] if !ok { - return false, fmt.Errorf("unknown channel: %s", channel) + return false, nil, fmt.Errorf("unknown channel: %s", channel) } if index < 0 || index >= expectedCellsCount { - return false, fmt.Errorf("unknown channel: %s", channel) - } - - doc.Find("tr").Each(func(i int, s *goquery.Selection) { - if strings.Contains(s.Text(), moduleName) { - cells := s.Find("td") - if cells.Length() == expectedCellsCount { - webVersion = strings.TrimSpace(cells.Eq(index).Text()) - } - } - }) - - if webVersion == "" { - return false, fmt.Errorf("version not found for module %s in channel %s", moduleName, channel) - } - - normalizedWebVersion := version.NormalizeSemVer(webVersion) - normalizedExpectedVersion := version.NormalizeSemVer(expectedVersion) - - if normalizedWebVersion != normalizedExpectedVersion { - return false, fmt.Errorf("version mismatch: expected %s, got %s", normalizedExpectedVersion, normalizedWebVersion) + return false, nil, fmt.Errorf("unknown channel: %s", channel) } - return true, nil -} - -// VerifyVersionAcrossAllEditions checks if the specified version exists for the given channel -// across all supported editions on releases.deckhouse.io. -func VerifyVersionAcrossAllEditions(editionURLs []string, channel, expectedVersion, moduleName, baseURL string) (bool, *ModuleVersionInfo, error) { versionInfo := &ModuleVersionInfo{ Module: moduleName, Versions: []ChannelVersion{}, } - hasMatch := false - var lastErr error + foundEdition := false + normalizedExpectedVersion := version.NormalizeSemVer(expectedVersion) - for _, editionURL := range editionURLs { - urlParts := strings.Split(editionURL, "/") - if len(urlParts) < minURLPartsCount { - fmt.Printf("Warning: invalid URL format: %s", editionURL) - continue + doc.Find("tr").Each(func(i int, s *goquery.Selection) { + cells := s.Find("td") + if cells.Length() != expectedCellsCount { + return } - edition := urlParts[len(urlParts)-1] - match, err := VerifyVersionInEdition(editionURL, channel, expectedVersion, moduleName) - if err != nil { - lastErr = err - fmt.Printf("Error checking %-7s edition on channel %s: %v", edition, cases.Title(language.Und).String(channel), err) - continue + edition := strings.TrimSpace(cells.Eq(0).Text()) + if edition == "" { + return } - if match { - hasMatch = true - versionInfo.Versions = append(versionInfo.Versions, ChannelVersion{ - Edition: edition, - Channel: channel, - Number: expectedVersion, - }) + foundEdition = true + + webVersion := strings.TrimSpace(cells.Eq(index).Text()) + if version.NormalizeSemVer(webVersion) != normalizedExpectedVersion { + return } + + versionInfo.Versions = append(versionInfo.Versions, ChannelVersion{ + Edition: edition, + Channel: channel, + Number: expectedVersion, + }) + }) + + if !foundEdition { + return false, nil, fmt.Errorf("module %s was not found on %s", moduleName, moduleURL) } - if !hasMatch { - return false, nil, fmt.Errorf("version %s not found in any edition for channel %s on %s: %w", expectedVersion, channel, baseURL, lastErr) + if len(versionInfo.Versions) == 0 { + return false, nil, fmt.Errorf("version %s not found for module %s in channel %s on %s", expectedVersion, moduleName, channel, moduleURL) } return true, versionInfo, nil @@ -174,20 +144,14 @@ const ( retryDelay = 60 * time.Second ) -// Supported editions to check on releases.deckhouse.io -var supportedEditions = []string{"fe", "ee", "ce", "se-plus"} - // CheckVersionWithRetries checks version on releases.deckhouse.io with retry logic. func CheckVersionWithRetries(channel, version, moduleName string, attempts int) error { - editionURLs := make([]string, 0, len(supportedEditions)) - for _, edition := range supportedEditions { - editionURLs = append(editionURLs, defaultReleasesBaseURL+"/"+edition) - } + moduleURL := defaultReleasesBaseURL + "/modules/" + url.PathEscape(moduleName) - fmt.Printf("Checking version %s on channel %s at %s...\n", version, channel, defaultReleasesBaseURL) + fmt.Printf("Checking version %s on channel %s at %s...\n", version, channel, moduleURL) for attempt := 1; attempt <= attempts; attempt++ { - checkPassed, versionInfo, err := VerifyVersionAcrossAllEditions(editionURLs, channel, version, moduleName, defaultReleasesBaseURL) + checkPassed, versionInfo, err := VerifyVersionOnModulePage(moduleURL, channel, version, moduleName) if err != nil { if attempt < attempts { fmt.Printf("Attempt %d/%d failed: %v", attempt, attempts, err) @@ -196,7 +160,7 @@ func CheckVersionWithRetries(channel, version, moduleName string, attempts int) continue } // Last attempt failed - fmt.Printf("Version %s validation failed on %s after %d attempts: %v", version, defaultReleasesBaseURL, attempts, err) + fmt.Printf("Version %s validation failed on %s after %d attempts: %v", version, moduleURL, attempts, err) return err } diff --git a/tools/moduleversions/internal/releases/checker_test.go b/tools/moduleversions/internal/releases/checker_test.go new file mode 100644 index 0000000000..97db4b6aa8 --- /dev/null +++ b/tools/moduleversions/internal/releases/checker_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releases + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const modulePageHTML = ` + + + + + + + + + + + + + +
EditionAlphaBetaEarly AccessStableRock Solid
Flant Edition1.8.21.7.21.7.21.7.11.7.1
Enterprise Edition1.8.21.7.21.7.21.7.11.7.1
Standard Edition+1.8.21.7.21.7.21.7.11.7.1
Community Edition1.8.21.7.21.7.21.7.11.7.1
+ +` + +func TestVerifyVersionOnModulePage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(modulePageHTML)) + })) + defer server.Close() + + checkPassed, versionInfo, err := VerifyVersionOnModulePage(server.URL, "alpha", "v1.8.2", "virtualization") + if err != nil { + t.Fatalf("VerifyVersionOnModulePage returned error: %v", err) + } + if !checkPassed { + t.Fatal("expected version check to pass") + } + if len(versionInfo.Versions) != 4 { + t.Fatalf("expected 4 matching editions, got %d", len(versionInfo.Versions)) + } +} + +func TestVerifyVersionOnModulePageNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(modulePageHTML)) + })) + defer server.Close() + + _, _, err := VerifyVersionOnModulePage(server.URL, "alpha", "1.8.3", "virtualization") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "version 1.8.3 not found") { + t.Fatalf("unexpected error: %v", err) + } +}