Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tools/moduleversions/Taskfile.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions tools/moduleversions/go.mod
Original file line number Diff line number Diff line change
@@ -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
)

Expand Down
2 changes: 0 additions & 2 deletions tools/moduleversions/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
120 changes: 42 additions & 78 deletions tools/moduleversions/internal/releases/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -54,116 +53,87 @@ 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,
"stable": 4,
"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
Expand All @@ -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)
Expand All @@ -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
}

Expand Down
75 changes: 75 additions & 0 deletions tools/moduleversions/internal/releases/checker_test.go
Original file line number Diff line number Diff line change
@@ -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 = `
<!doctype html>
<html>
<body>
<table>
<thead>
<tr><th>Edition</th><th>Alpha</th><th>Beta</th><th>Early Access</th><th>Stable</th><th>Rock Solid</th></tr>
</thead>
<tbody>
<tr><td><a href="/fe">Flant Edition</a></td><td>1.8.2</td><td>1.7.2</td><td>1.7.2</td><td>1.7.1</td><td>1.7.1</td></tr>
<tr><td><a href="/ee">Enterprise Edition</a></td><td>1.8.2</td><td>1.7.2</td><td>1.7.2</td><td>1.7.1</td><td>1.7.1</td></tr>
<tr><td><a href="/se-plus">Standard Edition+</a></td><td>1.8.2</td><td>1.7.2</td><td>1.7.2</td><td>1.7.1</td><td>1.7.1</td></tr>
<tr><td><a href="/ce">Community Edition</a></td><td>1.8.2</td><td>1.7.2</td><td>1.7.2</td><td>1.7.1</td><td>1.7.1</td></tr>
</tbody>
</table>
</body>
</html>`

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)
}
}
Loading