From d1d9394678dfccd548445f6df3bc7d5b5d6f957b Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Mon, 13 Oct 2025 17:40:34 +0300 Subject: [PATCH 1/2] Add All() to retrieve available versions Signed-off-by: Kimmo Lehto --- README.md | 26 +++- collection.go | 201 +++++++++++++++++++++++++++ collection_all_test.go | 246 +++++++++++++++++++++++++++++++++ collection_cache_test.go | 89 ++++++++++++ delta_test.go | 4 +- go.mod | 2 +- internal/cache/cache.go | 33 +++++ internal/cache/cache_test.go | 42 ++++++ internal/github/client.go | 145 +++++++++++++++++++ internal/github/client_test.go | 141 +++++++++++++++++++ latest.go | 130 +++++++++++++---- latest_test.go | 58 +++++++- version.go | 69 ++++++--- version_test.go | 6 + 14 files changed, 1141 insertions(+), 51 deletions(-) create mode 100644 collection_all_test.go create mode 100644 collection_cache_test.go create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/github/client.go create mode 100644 internal/github/client_test.go diff --git a/README.md b/README.md index bd6d50e..1e8f29d 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,30 @@ func main() { } ``` +### List released versions + +```go +import ( + "context" + "fmt" + + "github.com/k0sproject/version" +) + +func main() { + ctx := context.Background() + versions, err := version.All(ctx) + if err != nil { + panic(err) + } + for _, v := range versions { + fmt.Println(v) + } +} +``` + +The first call hydrates a cache under the OS cache directory (honouring `XDG_CACHE_HOME` when set) and reuses it for subsequent listings. + ### `k0s_sort` executable A command-line interface to the package. Can be used to sort lists of versions or to obtain the latest version number. @@ -131,4 +155,4 @@ Usage: k0s_sort [options] [filename ...] ## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fk0sproject%2Fversion.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fk0sproject%2Fversion?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fk0sproject%2Fversion.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fk0sproject%2Fversion?ref=badge_large) diff --git a/collection.go b/collection.go index eb3c1a5..8930265 100644 --- a/collection.go +++ b/collection.go @@ -1,9 +1,30 @@ package version import ( + "bufio" + "context" + "errors" "fmt" + "io" + "maps" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/k0sproject/version/internal/cache" + "github.com/k0sproject/version/internal/github" ) +// CacheMaxAge is the maximum duration a cached version list is considered fresh +// before forcing a refresh from GitHub. +const CacheMaxAge = 60 * time.Minute + +// ErrCacheMiss is returned when no cached version data is available. +var ErrCacheMiss = errors.New("version: cache miss") + // Collection is a type that implements the sort.Interface interface // so that versions can be sorted. type Collection []*Version @@ -31,3 +52,183 @@ func (c Collection) Less(i, j int) bool { func (c Collection) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +// newCollectionFromCache returns the cached versions and the file's modification time. +// It returns ErrCacheMiss when no usable cache exists. +func newCollectionFromCache() (Collection, time.Time, error) { + path, err := cache.File() + if err != nil { + return nil, time.Time{}, fmt.Errorf("locate cache: %w", err) + } + + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, time.Time{}, ErrCacheMiss + } + return nil, time.Time{}, fmt.Errorf("open cache: %w", err) + } + defer func() { + _ = f.Close() + }() + + info, err := f.Stat() + if err != nil { + return nil, time.Time{}, fmt.Errorf("stat cache: %w", err) + } + + collection, readErr := readCollection(f) + if readErr != nil { + return nil, time.Time{}, fmt.Errorf("read cache: %w", readErr) + } + if len(collection) == 0 { + return nil, info.ModTime(), ErrCacheMiss + } + + return collection, info.ModTime(), nil +} + +// writeCache persists the collection to the cache file, one version per line. +func (c Collection) writeCache() error { + path, err := cache.File() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + ordered := slices.Clone(c) + ordered = slices.DeleteFunc(ordered, func(v *Version) bool { + return v == nil + }) + slices.SortFunc(ordered, func(a, b *Version) int { + return a.Compare(b) + }) + slices.Reverse(ordered) + + var b strings.Builder + for _, v := range ordered { + b.WriteString(v.String()) + b.WriteByte('\n') + } + + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// All returns all known k0s versions using the provided context. It refreshes +// the local cache by querying GitHub for tags newer than the cache +// modification time when the cache is older than CacheMaxAge. The cache is +// skipped if the remote lookup fails and no cached data exists. +func All(ctx context.Context) (Collection, error) { + result, err := loadAll(ctx, sharedHTTPClient, false) + return result.versions, err +} + +// Refresh fetches versions from GitHub regardless of cache freshness, updating the cache on success. +func Refresh() (Collection, error) { + return RefreshContext(context.Background()) +} + +// RefreshContext fetches versions from GitHub regardless of cache freshness, +// updating the cache on success using the provided context. +func RefreshContext(ctx context.Context) (Collection, error) { + result, err := loadAll(ctx, sharedHTTPClient, true) + return result.versions, err +} + +type loadResult struct { + versions Collection + usedFallback bool +} + +func loadAll(ctx context.Context, httpClient *http.Client, force bool) (loadResult, error) { + cached, modTime, cacheErr := newCollectionFromCache() + if cacheErr != nil && !errors.Is(cacheErr, ErrCacheMiss) { + return loadResult{}, cacheErr + } + + known := make(map[string]*Version, len(cached)) + for _, v := range cached { + if v == nil { + continue + } + known[v.String()] = v + } + + cacheStale := force || errors.Is(cacheErr, ErrCacheMiss) || modTime.IsZero() || time.Since(modTime) > CacheMaxAge + if !cacheStale { + return loadResult{versions: collectionFromMap(known)}, nil + } + + client := github.NewClient(httpClient) + tags, err := client.TagsSince(ctx, modTime) + if err != nil { + if force || len(known) == 0 { + return loadResult{}, err + } + return loadResult{versions: collectionFromMap(known), usedFallback: true}, nil + } + + var updated bool + for _, tag := range tags { + version, err := NewVersion(tag) + if err != nil { + continue + } + key := version.String() + if _, exists := known[key]; exists { + continue + } + known[key] = version + updated = true + } + + result := collectionFromMap(known) + + if updated || errors.Is(cacheErr, ErrCacheMiss) || force { + if err := result.writeCache(); err != nil { + return loadResult{}, err + } + } + + return loadResult{versions: result}, nil +} + +func collectionFromMap(m map[string]*Version) Collection { + if len(m) == 0 { + return nil + } + values := slices.Collect(maps.Values(m)) + values = slices.DeleteFunc(values, func(v *Version) bool { + return v == nil + }) + slices.SortFunc(values, func(a, b *Version) int { + return a.Compare(b) + }) + return Collection(values) +} + +func readCollection(r io.Reader) (Collection, error) { + var collection Collection + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + v, err := NewVersion(line) + if err != nil { + continue + } + collection = append(collection, v) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return collection, nil +} diff --git a/collection_all_test.go b/collection_all_test.go new file mode 100644 index 0000000..4a6af07 --- /dev/null +++ b/collection_all_test.go @@ -0,0 +1,246 @@ +package version + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/k0sproject/version/internal/cache" +) + +func TestAllFetchesAndCaches(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + serverURL := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/k0sproject/k0s/tags": + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintf(w, `[ + {"name":"v1.25.0+k0s.0","commit":{"url":"%s/repos/k0sproject/k0s/commits/c1"}}, + {"name":"v1.24.3+k0s.0","commit":{"url":"%s/repos/k0sproject/k0s/commits/c2"}} + ]`, serverURL, serverURL); err != nil { + t.Fatalf("write tags response: %v", err) + } + case "/repos/k0sproject/k0s/commits/c1": + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprint(w, `{"commit":{"committer":{"date":"2024-03-10T00:00:00Z"}}}`); err != nil { + t.Fatalf("write commit response: %v", err) + } + case "/repos/k0sproject/k0s/commits/c2": + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprint(w, `{"commit":{"committer":{"date":"2024-02-01T00:00:00Z"}}}`); err != nil { + t.Fatalf("write commit response: %v", err) + } + default: + http.NotFound(w, r) + } + })) + serverURL = server.URL + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + ctx := context.Background() + versions, err := All(ctx) + if err != nil { + t.Fatalf("All() returned error: %v", err) + } + + if len(versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(versions)) + } + + if versions[0].String() != "v1.24.3+k0s.0" { + t.Fatalf("unexpected first version %q", versions[0]) + } + if versions[1].String() != "v1.25.0+k0s.0" { + t.Fatalf("unexpected second version %q", versions[1]) + } + + cachePath, err := cache.File() + if err != nil { + t.Fatalf("cache.File() error: %v", err) + } + + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatalf("reading cache: %v", err) + } + + want := "v1.25.0+k0s.0\nv1.24.3+k0s.0\n" + if string(data) != want { + t.Fatalf("cache contents = %q, want %q", string(data), want) + } +} + +func TestAllFallsBackToCacheOnError(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + seed, err := NewCollection("v1.24.3+k0s.0") + if err != nil { + t.Fatalf("seeding collection: %v", err) + } + if err := seed.writeCache(); err != nil { + t.Fatalf("priming cache: %v", err) + } + + failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer failServer.Close() + + t.Setenv("GITHUB_API_URL", failServer.URL) + t.Setenv("GITHUB_TOKEN", "") + + ctx := context.Background() + versions, err := All(ctx) + if err != nil { + t.Fatalf("All() returned error: %v", err) + } + + if len(versions) != 1 { + t.Fatalf("expected 1 version from cache, got %d", len(versions)) + } + if versions[0].String() != "v1.24.3+k0s.0" { + t.Fatalf("unexpected cached version %q", versions[0]) + } +} + +func TestAllReturnsCachedWhenStaleAndRemoteFails(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + seed, err := NewCollection("v1.24.3+k0s.0") + if err != nil { + t.Fatalf("seeding collection: %v", err) + } + if err := seed.writeCache(); err != nil { + t.Fatalf("priming cache: %v", err) + } + + cachePath, err := cache.File() + if err != nil { + t.Fatalf("cache.File() error: %v", err) + } + + older := time.Now().Add(-(CacheMaxAge + time.Minute)) + if err := os.Chtimes(cachePath, older, older); err != nil { + t.Fatalf("setting cache time: %v", err) + } + + failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer failServer.Close() + + t.Setenv("GITHUB_API_URL", failServer.URL) + t.Setenv("GITHUB_TOKEN", "") + + ctx := context.Background() + versions, err := All(ctx) + if err != nil { + t.Fatalf("All() returned error: %v", err) + } + + if len(versions) != 1 { + t.Fatalf("expected 1 version from cache, got %d", len(versions)) + } + if versions[0].String() != "v1.24.3+k0s.0" { + t.Fatalf("unexpected cached version %q", versions[0]) + } +} + +func TestRefreshFailsWhenRemoteFails(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + seed, err := NewCollection("v1.24.3+k0s.0") + if err != nil { + t.Fatalf("seeding collection: %v", err) + } + if err := seed.writeCache(); err != nil { + t.Fatalf("priming cache: %v", err) + } + + failure := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer failure.Close() + + t.Setenv("GITHUB_API_URL", failure.URL) + t.Setenv("GITHUB_TOKEN", "") + + if _, err := Refresh(); err == nil { + t.Fatal("expected error when refresh fails") + } +} + +func TestAllReturnsErrorWhenNoCacheAndRemoteFails(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer failServer.Close() + + t.Setenv("GITHUB_API_URL", failServer.URL) + t.Setenv("GITHUB_TOKEN", "") + + ctx := context.Background() + if _, err := All(ctx); err == nil { + t.Fatal("expected error when remote fails without cache") + } +} + +func TestAllSendsIfModifiedSince(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + seed, err := NewCollection("v1.24.3+k0s.0") + if err != nil { + t.Fatalf("seeding collection: %v", err) + } + if err := seed.writeCache(); err != nil { + t.Fatalf("priming cache: %v", err) + } + + cachePath, err := cache.File() + if err != nil { + t.Fatalf("cache.File() error: %v", err) + } + + older := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(cachePath, older, older); err != nil { + t.Fatalf("setting cache time: %v", err) + } + + var received string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/k0sproject/k0s/tags" { + received = r.Header.Get("If-Modified-Since") + w.WriteHeader(http.StatusNotModified) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + ctx := context.Background() + versions, err := All(ctx) + if err != nil { + t.Fatalf("All() returned error: %v", err) + } + if len(versions) != 1 { + t.Fatalf("expected cached version, got %d", len(versions)) + } + if received == "" { + t.Fatal("expected If-Modified-Since header to be set") + } +} diff --git a/collection_cache_test.go b/collection_cache_test.go new file mode 100644 index 0000000..255cc1f --- /dev/null +++ b/collection_cache_test.go @@ -0,0 +1,89 @@ +package version + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/k0sproject/version/internal/cache" +) + +func TestCollectionWriteCache(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + c, err := NewCollection("v1.0.0+k0s.1", "v1.0.1+k0s.0") + if err != nil { + t.Fatalf("NewCollection() error = %v", err) + } + + if err := c.writeCache(); err != nil { + t.Fatalf("writeCache() error = %v", err) + } + + path, err := cache.File() + if err != nil { + t.Fatalf("cache.File() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + want := "v1.0.1+k0s.0\nv1.0.0+k0s.1\n" + if string(data) != want { + t.Fatalf("cache contents = %q, want %q", string(data), want) + } +} + +func TestNewCollectionFromCache(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + path, err := cache.File() + if err != nil { + t.Fatalf("cache.File() error = %v", err) + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + contents := "v1.0.0+k0s.1\ninvalid\n#comment\n\n" + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + collection, modTime, err := newCollectionFromCache() + if err != nil { + t.Fatalf("newCollectionFromCache() error = %v", err) + } + if modTime.IsZero() { + t.Fatal("expected modTime to be set") + } + + if len(collection) != 1 { + t.Fatalf("expected 1 version, got %d", len(collection)) + } + + if got := collection[0].String(); got != "v1.0.0+k0s.1" { + t.Fatalf("unexpected version %q", got) + } + + stat, err := os.Stat(path) + if err != nil { + t.Fatalf("stat cache file: %v", err) + } + if !modTime.Equal(stat.ModTime()) { + t.Fatalf("modTime %v should match file mod time %v", modTime, stat.ModTime()) + } +} + +func TestNewCollectionFromCacheMiss(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + _, _, err := newCollectionFromCache() + if !errors.Is(err, ErrCacheMiss) { + t.Fatalf("expected ErrCacheMiss, got %v", err) + } +} diff --git a/delta_test.go b/delta_test.go index 622919e..c7910c3 100644 --- a/delta_test.go +++ b/delta_test.go @@ -48,8 +48,8 @@ func ExampleDelta() { a, _ := version.NewVersion("v1.0.0") b, _ := version.NewVersion("v1.2.1") delta := version.NewDelta(a, b) - fmt.Printf("patch upgrade: %t\n", delta.PatchUpgrade) - fmt.Println(delta.String()) + _, _ = fmt.Printf("patch upgrade: %t\n", delta.PatchUpgrade) + _, _ = fmt.Println(delta.String()) // Output: // patch upgrade: false // a non-consecutive minor upgrade from v1.0 to v1.2 diff --git a/go.mod b/go.mod index 3841c52..3432d80 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/k0sproject/version -go 1.17 +go 1.25 diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..10a3adf --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,33 @@ +package cache + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + cacheDirName = "k0s_version" + cacheFileName = "known_versions.txt" +) + +// File returns the absolute path to the known versions cache file. +// The base directory honors XDG_CACHE_HOME when set; otherwise it +// uses os.UserCacheDir for a platform-aware default. +func File() (string, error) { + if base := os.Getenv("XDG_CACHE_HOME"); base != "" { + return filepath.Join(base, cacheDirName, cacheFileName), nil + } + + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("determine cache directory: %w", err) + } + + if base == "" { + return "", errors.New("cache base directory is empty") + } + + return filepath.Join(base, cacheDirName, cacheFileName), nil +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..9bc1fd9 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,42 @@ +package cache_test + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/k0sproject/version/internal/cache" +) + +func TestFileHonorsXDGCacheHome(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, "xdg")) + + got, err := cache.File() + if err != nil { + t.Fatalf("File() returned error: %v", err) + } + + want := filepath.Join(tmp, "xdg", "k0s_version", "known_versions.txt") + if got != want { + t.Fatalf("File() = %q, want %q", got, want) + } +} + +func TestFileProvidesPlatformDefault(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", "") + if runtime.GOOS == "windows" { + t.Setenv("LOCALAPPDATA", t.TempDir()) + } + + got, err := cache.File() + if err != nil { + t.Fatalf("File() returned error: %v", err) + } + + suffix := filepath.Join("k0s_version", "known_versions.txt") + if !strings.HasSuffix(got, suffix) { + t.Fatalf("File() path %q does not end with %q", got, suffix) + } +} diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..72e6695 --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,145 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "strconv" + "strings" + "time" +) + +const ( + defaultBaseURL = "https://api.github.com" + repoOwner = "k0sproject" + repoName = "k0s" + perPage = 100 + headerAccept = "application/vnd.github+json" + headerUserAgent = "github.com/k0sproject/version" +) + +// Client wraps GitHub REST usage tailored for listing tags. +type Client struct { + httpClient *http.Client + baseURL string + token string +} + +// NewClient creates a GitHub client. If httpClient is nil a default +// client with a 10s timeout is used. The base URL can be overridden via +// the GITHUB_API_URL environment variable (useful for tests or GHES). +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + base := os.Getenv("GITHUB_API_URL") + if base == "" { + base = defaultBaseURL + } + + return &Client{ + httpClient: httpClient, + baseURL: strings.TrimRight(base, "/"), + token: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")), + } +} + +// TagsSince returns tag names that GitHub reports as updated since the provided time. +// When since is zero, all tags are returned (subject to pagination of the tags +// endpoint itself). +func (c *Client) TagsSince(ctx context.Context, since time.Time) ([]string, error) { + if c == nil { + return nil, errors.New("github client is nil") + } + + if c.httpClient == nil { + return nil, errors.New("http client is nil") + } + + sinceHeader := "" + if !since.IsZero() { + sinceHeader = since.UTC().Format(http.TimeFormat) + } + + var tags []string + + for page := 1; ; page++ { + tagsURL := fmt.Sprintf("%s/%s", strings.TrimRight(c.baseURL, "/"), path.Join("repos", repoOwner, repoName, "tags")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tagsURL, nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Set("per_page", strconv.Itoa(perPage)) + q.Set("page", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + + req.Header.Set("Accept", headerAccept) + req.Header.Set("User-Agent", headerUserAgent) + if sinceHeader != "" { + req.Header.Set("If-Modified-Since", sinceHeader) + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusNotModified { + return tags, nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github tags request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var payload []tagResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decode tags payload: %w", err) + } + + if len(payload) == 0 { + break + } + + for _, tag := range payload { + tags = append(tags, tag.Name) + } + + if !hasNextPage(resp.Header.Get("Link")) { + break + } + } + + return tags, nil +} + +func hasNextPage(linkHeader string) bool { + for _, part := range strings.Split(linkHeader, ",") { + section := strings.TrimSpace(part) + if section == "" { + continue + } + if strings.Contains(section, "rel=\"next\"") { + return true + } + } + return false +} + +type tagResponse struct { + Name string `json:"name"` +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..7004959 --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,141 @@ +package github_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + internalgithub "github.com/k0sproject/version/internal/github" +) + +func TestTagsSinceDoesNotFetchCommits(t *testing.T) { + since := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + + serverURL := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/k0sproject/k0s/tags": + if got, want := r.Header.Get("If-Modified-Since"), since.UTC().Format(http.TimeFormat); got != want { + t.Fatalf("expected If-Modified-Since header %q, got %q", want, got) + } + + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprintf(w, `[ + {"name":"v1.2.0","commit":{"url":"%s/repos/k0sproject/k0s/commits/a1"}}, + {"name":"v1.1.0","commit":{"url":"%s/repos/k0sproject/k0s/commits/a2"}} + ]`, serverURL, serverURL); err != nil { + t.Fatalf("write tags response: %v", err) + } + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + serverURL = server.URL + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + client := internalgithub.NewClient(server.Client()) + + got, err := client.TagsSince(context.Background(), since) + if err != nil { + t.Fatalf("TagsSince returned error: %v", err) + } + + want := []string{"v1.2.0", "v1.1.0"} + if len(got) != len(want) { + t.Fatalf("expected %d tags, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected tag[%d]: got %q want %q", i, got[i], want[i]) + } + } +} + +func TestTagsSinceHandlesPagination(t *testing.T) { + since := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC) + + serverURL := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/k0sproject/k0s/tags": + page := r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json") + switch page { + case "1": + w.Header().Set("Link", fmt.Sprintf("<%s/repos/k0sproject/k0s/tags?page=2>; rel=\"next\"", serverURL)) + if _, err := fmt.Fprintf(w, `[ + {"name":"v1.4.0","commit":{"url":"%s/repos/k0sproject/k0s/commits/c1"}} + ]`, serverURL); err != nil { + t.Fatalf("write page1 tags: %v", err) + } + case "2": + if _, err := fmt.Fprintf(w, `[ + {"name":"v1.3.0","commit":{"url":"%s/repos/k0sproject/k0s/commits/c2"}} + ]`, serverURL); err != nil { + t.Fatalf("write page2 tags: %v", err) + } + default: + if _, err := fmt.Fprint(w, `[]`); err != nil { + t.Fatalf("write empty page: %v", err) + } + } + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + serverURL = server.URL + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + client := internalgithub.NewClient(server.Client()) + + got, err := client.TagsSince(context.Background(), since) + if err != nil { + t.Fatalf("TagsSince returned error: %v", err) + } + + want := []string{"v1.4.0", "v1.3.0"} + if len(got) != len(want) { + t.Fatalf("expected %d tags, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected tag[%d]: got %q want %q", i, got[i], want[i]) + } + } +} + +func TestTagsSinceNotModified(t *testing.T) { + var seenHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeader = r.Header.Get("If-Modified-Since") + w.WriteHeader(http.StatusNotModified) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + client := internalgithub.NewClient(server.Client()) + + since := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + got, err := client.TagsSince(context.Background(), since) + if err != nil { + t.Fatalf("TagsSince returned error: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected no tags on 304, got %v", got) + } + + if seenHeader == "" { + t.Fatal("expected If-Modified-Since header to be sent") + } +} diff --git a/latest.go b/latest.go index 74914cc..4b6f30e 100644 --- a/latest.go +++ b/latest.go @@ -1,62 +1,148 @@ package version import ( + "context" "fmt" "io" "net/http" "net/url" + "os" "strings" "time" ) -var Timeout = time.Second * 10 +var ( + // Timeout controls the default HTTP client timeout for remote lookups. + Timeout = 10 * time.Second + sharedHTTPClient = &http.Client{Timeout: Timeout} +) -// LatestByPrerelease returns the latest released k0s version, if preok is true, prereleases are also accepted. +// LatestByPrerelease returns the latest released k0s version. When allowpre is +// true prereleases are also accepted. func LatestByPrerelease(allowpre bool) (*Version, error) { - u := &url.URL{ - Scheme: "https", - Host: "docs.k0sproject.io", + return LatestByPrereleaseContext(context.Background(), allowpre) +} + +// LatestByPrereleaseContext returns the latest released k0s version using the +// provided context. When allowpre is true prereleases are also accepted. +func LatestByPrereleaseContext(ctx context.Context, allowpre bool) (*Version, error) { + result, err := loadAll(ctx, sharedHTTPClient, false) + versions := result.versions + + var candidate *Version + if err == nil { + candidate = selectLatest(versions, allowpre) + if candidate != nil && !result.usedFallback { + return candidate, nil + } } - if allowpre { - u.Path = "latest.txt" - } else { - u.Path = "stable.txt" + fallback, fallbackErr := fetchLatestFromDocs(ctx, sharedHTTPClient, allowpre) + if fallbackErr == nil { + if candidate == nil { + return fallback, nil + } + if fallback.GreaterThan(candidate) { + return fallback, nil + } + return candidate, nil } - v, err := httpGet(u.String()) + if candidate != nil { + return candidate, nil + } if err != nil { - return nil, err + return nil, fmt.Errorf("list versions: %w", err) } - - return NewVersion(v) + return nil, fallbackErr } -// LatestStable returns the semantically sorted latest non-prerelease version from the online repository +// LatestStable returns the semantically sorted latest non-prerelease version +// from the cached collection. func LatestStable() (*Version, error) { return LatestByPrerelease(false) } -// LatestVersion returns the semantically sorted latest version even if it is a prerelease from the online repository +// LatestStableContext returns the semantically sorted latest non-prerelease +// version from the cached collection using the provided context. +func LatestStableContext(ctx context.Context) (*Version, error) { + return LatestByPrereleaseContext(ctx, false) +} + +// Latest returns the semantically sorted latest version even if it is a +// prerelease from the cached collection. func Latest() (*Version, error) { return LatestByPrerelease(true) } -func httpGet(u string) (string, error) { - client := &http.Client{ - Timeout: Timeout, +// LatestContext returns the semantically sorted latest version even if it is a +// prerelease from the cached collection using the provided context. +func LatestContext(ctx context.Context) (*Version, error) { + return LatestByPrereleaseContext(ctx, true) +} + +func selectLatest(collection Collection, allowpre bool) *Version { + for i := len(collection) - 1; i >= 0; i-- { + v := collection[i] + if v == nil { + continue + } + if !allowpre && v.IsPrerelease() { + continue + } + return v + } + return nil +} + +func fetchLatestFromDocs(ctx context.Context, client *http.Client, allowpre bool) (*Version, error) { + path := "stable.txt" + if allowpre { + path = "latest.txt" + } + + base := strings.TrimSpace(os.Getenv("K0S_VERSION_DOCS_BASE_URL")) + if base == "" { + base = "https://docs.k0sproject.io" + } + + baseURL, err := url.Parse(base) + if err != nil { + return nil, fmt.Errorf("parse docs base url %q: %w", base, err) + } + baseURL.Path = path + + text, err := httpGet(ctx, client, baseURL.String()) + if err != nil { + return nil, err + } + + return NewVersion(text) +} + +func httpGet(ctx context.Context, client *http.Client, u string) (string, error) { + if client == nil { + client = sharedHTTPClient + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", fmt.Errorf("create request for %s: %w", u, err) } - resp, err := client.Get(u) + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("http request to %s failed: %w", u, err) } + defer func() { + _ = resp.Body.Close() + }() if resp.Body == nil { return "", fmt.Errorf("http request to %s failed: nil body", u) } - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("http request to %s failed: backend returned %d", u, resp.StatusCode) } @@ -65,9 +151,5 @@ func httpGet(u string) (string, error) { return "", fmt.Errorf("http request to %s failed: %w when reading body", u, err) } - if err := resp.Body.Close(); err != nil { - return "", fmt.Errorf("http request to %s failed: %w when closing body", u, err) - } - return strings.TrimSpace(string(body)), nil } diff --git a/latest_test.go b/latest_test.go index 998b20b..ce92cbb 100644 --- a/latest_test.go +++ b/latest_test.go @@ -1,14 +1,66 @@ package version_test import ( - "regexp" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" + "time" "github.com/k0sproject/version" ) func TestLatestByPrerelease(t *testing.T) { - r, err := version.LatestByPrerelease(false) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + repoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/k0sproject/k0s/tags" { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[ + {"name":"v1.25.0+k0s.0"}, + {"name":"v1.24.3+k0s.0"} + ]`) + return + } + http.NotFound(w, r) + })) + defer repoServer.Close() + + docsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/stable.txt": + _, _ = fmt.Fprint(w, "v1.25.1+k0s.0") + case "/latest.txt": + _, _ = fmt.Fprint(w, "v1.26.0+k0s.0-rc.1") + default: + http.NotFound(w, r) + } + })) + defer docsServer.Close() + + t.Setenv("GITHUB_API_URL", repoServer.URL) + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("K0S_VERSION_DOCS_BASE_URL", docsServer.URL) + + stable, err := version.LatestByPrerelease(false) + NoError(t, err) + Equal(t, "v1.25.0+k0s.0", stable.String()) + + cachePath := filepath.Join(os.Getenv("XDG_CACHE_HOME"), "k0s_version", "known_versions.txt") + stale := time.Now().Add(-(version.CacheMaxAge + time.Minute)) + if err := os.Chtimes(cachePath, stale, stale); err != nil { + t.Fatalf("setting cache stale: %v", err) + } + + repoServer.Close() + + stableFallback, err := version.LatestByPrerelease(false) + NoError(t, err) + Equal(t, "v1.25.1+k0s.0", stableFallback.String()) + + latest, err := version.LatestByPrerelease(true) NoError(t, err) - True(t, regexp.MustCompile(`^v\d+\.\d+\.\d+\+k0s\.\d+$`).MatchString(r.String())) + Equal(t, "v1.26.0+k0s.0-rc.1", latest.String()) } diff --git a/version.go b/version.go index 3b9402c..3b8cc58 100644 --- a/version.go +++ b/version.go @@ -97,29 +97,54 @@ func NewVersion(v string) (*Version, error) { if len(metaParts) == 1 { version.meta = meta } else { - // parse the k0s. part from metadata - // and rebuild a new metadata string without it - var newMeta strings.Builder - for idx, part := range metaParts { - if part == k0s && idx < len(metaParts)-1 { - k0sV, err := strconv.ParseUint(metaParts[idx+1], 10, 32) - if err == nil { - version.isK0s = true - version.k0s = int(k0sV) - } - } else if idx > 0 && metaParts[idx-1] != k0s { - newMeta.WriteString(part) - if idx < len(metaParts)-1 { - newMeta.WriteString(".") - } + var filtered []string + for i := 0; i < len(metaParts); { + part := metaParts[i] + if part != k0s { + filtered = append(filtered, part) + i++ + continue } + if i+1 >= len(metaParts) { + filtered = append(filtered, part) + i++ + continue + } + next := metaParts[i+1] + digits, suffix := splitLeadingDigits(next) + if digits == "" { + filtered = append(filtered, part) + i++ + continue + } + k0sV, err := strconv.ParseUint(digits, 10, 32) + if err != nil { + filtered = append(filtered, part) + i++ + continue + } + version.isK0s = true + version.k0s = int(k0sV) + if suffix != "" { + filtered = append(filtered, suffix) + } + i += 2 } - version.meta = newMeta.String() + version.meta = strings.Join(filtered, ".") } return version, nil } +func splitLeadingDigits(s string) (string, string) { + for i, r := range s { + if r < '0' || r > '9' { + return s[:i], s[i:] + } + } + return s, "" +} + // Segments returns the numerical segments of the version. The returned slice is always maxSegments long. Missing segments are zeroes. Eg 1,1,0 from v1.1 func (v *Version) Segments() []int { segments := make([]int, maxSegments) // Create a slice with maxSegments length @@ -221,10 +246,14 @@ func (v *Version) String() string { sb.WriteRune('.') sb.WriteString(strconv.Itoa(v.k0s)) if v.meta != "" { - sb.WriteRune('.') + if strings.HasPrefix(v.meta, "-") { + sb.WriteString(v.meta) + } else { + sb.WriteRune('.') + sb.WriteString(v.meta) + } } - } - if v.meta != "" { + } else if v.meta != "" { sb.WriteString(v.meta) } @@ -246,7 +275,7 @@ func (v *Version) Equal(b *Version) bool { func (v *Version) Compare(b *Version) int { switch { case v == nil && b == nil: - return 0 + return 0 case v == nil && b != nil: return -1 case v != nil && b == nil: diff --git a/version_test.go b/version_test.go index adb0a72..3cebd69 100644 --- a/version_test.go +++ b/version_test.go @@ -68,6 +68,12 @@ func TestNewVersion(t *testing.T) { Equal(t, "v1.23.3", v.Base()) _, err = version.NewVersion("v1.23.b+k0s.1") Error(t, err) + + t.Run("preserves metadata suffix", func(t *testing.T) { + candidate, err := version.NewVersion("v1.26.0+k0s.0-rc.1") + NoError(t, err) + Equal(t, "v1.26.0+k0s.0-rc.1", candidate.String()) + }) } func TestWithK0s(t *testing.T) { From 0983fa1d03a92f601387d9348cab1acdbd839cc1 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Mon, 13 Oct 2025 17:41:39 +0300 Subject: [PATCH 2/2] Add UpgradePath() to calculate upgrade paths Signed-off-by: Kimmo Lehto --- README.md | 16 +++++ upgrade.go | 155 ++++++++++++++++++++++++++++++++++++++++++++++++ upgrade_test.go | 140 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 upgrade.go create mode 100644 upgrade_test.go diff --git a/README.md b/README.md index 1e8f29d..9282a2d 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,22 @@ func main() { The first call hydrates a cache under the OS cache directory (honouring `XDG_CACHE_HOME` when set) and reuses it for subsequent listings. +### Plan an upgrade path + +```go +from := version.MustParse("v1.24.1+k0s.0") +to := version.MustParse("v1.26.1+k0s.0") +path, err := from.UpgradePath(to) +if err != nil { + panic(err) +} +for _, step := range path { + fmt.Println(step) +} +``` + +The resulting slice contains the latest patch of each intermediate minor and the target (including prereleases when the target is one). + ### `k0s_sort` executable A command-line interface to the package. Can be used to sort lists of versions or to obtain the latest version number. diff --git a/upgrade.go b/upgrade.go new file mode 100644 index 0000000..87102e9 --- /dev/null +++ b/upgrade.go @@ -0,0 +1,155 @@ +package version + +import ( + "context" + "errors" + "fmt" + "sort" +) + +type minorKey struct { + major int + minor int +} + +func minorFromVersion(v *Version) minorKey { + segments := v.Segments() + return minorKey{major: segments[0], minor: segments[1]} +} + +func compareMinor(a, b minorKey) int { + switch { + case a.major < b.major: + return -1 + case a.major > b.major: + return 1 + case a.minor < b.minor: + return -1 + case a.minor > b.minor: + return 1 + default: + return 0 + } +} + +// UpgradePath returns the recommended stable upgrade path from the receiver to target. +// It selects the latest stable patch for each minor along the way and appends the +// target when needed. Intermediate prereleases are skipped except when the target +// itself is a prerelease. +func (v *Version) UpgradePath(target *Version) (Collection, error) { + if v == nil { + return nil, errors.New("current version is nil") + } + if target == nil { + return nil, errors.New("target version is nil") + } + if target.LessThan(v) { + return nil, fmt.Errorf("target version %s is older than %s", target.String(), v.String()) + } + + all, err := All(context.Background()) + if err != nil { + return nil, err + } + + versionsByString := make(map[string]*Version, len(all)) + latestByMinor := make(map[minorKey]*Version) + for _, candidate := range all { + if candidate == nil { + continue + } + key := candidate.String() + versionsByString[key] = candidate + if candidate.IsPrerelease() { + continue + } + + minor := minorFromVersion(candidate) + if current, ok := latestByMinor[minor]; !ok || current.LessThan(candidate) { + latestByMinor[minor] = candidate + } + } + + startMinor := minorFromVersion(v) + targetMinor := minorFromVersion(target) + + keys := make([]minorKey, 0, len(latestByMinor)) + for key := range latestByMinor { + keys = append(keys, key) + } + + sort.Slice(keys, func(i, j int) bool { + return compareMinor(keys[i], keys[j]) < 0 + }) + + current := v + path := Collection{} + + for _, key := range keys { + if compareMinor(key, startMinor) < 0 { + continue + } + if compareMinor(key, targetMinor) > 0 { + break + } + + candidate := latestByMinor[key] + if candidate == nil { + continue + } + + if target.IsPrerelease() && compareMinor(key, targetMinor) == 0 && candidate.GreaterThan(target) { + continue + } + + if !target.IsPrerelease() && candidate.GreaterThan(target) { + continue + } + + if !candidate.GreaterThan(current) { + continue + } + + path = append(path, candidate) + current = candidate + } + + targetString := target.String() + targetVersion := versionsByString[targetString] + if targetVersion == nil { + targetVersion = target + } + + if target.IsPrerelease() { + if current.LessThan(targetVersion) { + path = append(path, targetVersion) + } else if len(path) == 0 || path[len(path)-1].String() != targetString { + path = append(path, targetVersion) + } + } else { + if len(path) == 0 || path[len(path)-1].String() != targetString { + if targetVersion.GreaterThan(current) { + path = append(path, targetVersion) + } + } + } + + deduped := Collection{} + seen := make(map[string]struct{}, len(path)) + for _, candidate := range path { + if candidate == nil { + continue + } + if !candidate.GreaterThan(v) { + continue + } + key := candidate.String() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + deduped = append(deduped, candidate) + } + + return deduped, nil +} diff --git a/upgrade_test.go b/upgrade_test.go new file mode 100644 index 0000000..9083a54 --- /dev/null +++ b/upgrade_test.go @@ -0,0 +1,140 @@ +package version + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func mustVersion(t *testing.T, input string) *Version { + t.Helper() + v, err := NewVersion(input) + if err != nil { + t.Fatalf("failed to parse version %q: %v", input, err) + } + return v +} + +func setupTagServer(t *testing.T) *httptest.Server { + t.Helper() + + tags := []struct { + name string + commit string + date string + }{ + {"v1.26.1+k0s.0", "c1", "2024-03-10T00:00:00Z"}, + {"v1.26.0+k0s.0", "c2", "2024-03-05T00:00:00Z"}, + {"v1.26.0-rc.1+k0s.0", "c3", "2024-02-25T00:00:00Z"}, + {"v1.25.1+k0s.0", "c4", "2024-02-10T00:00:00Z"}, + {"v1.25.0+k0s.0", "c5", "2024-01-31T00:00:00Z"}, + {"v1.24.3+k0s.0", "c6", "2024-01-15T00:00:00Z"}, + {"v1.24.1+k0s.0", "c7", "2023-12-20T00:00:00Z"}, + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/k0sproject/k0s/tags": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, "[") + for i, tag := range tags { + if i > 0 { + _, _ = fmt.Fprint(w, ",") + } + _, _ = fmt.Fprintf(w, "{\"name\":\"%s\",\"commit\":{\"url\":\"%s/repos/k0sproject/k0s/commits/%s\"}}", tag.name, server.URL, tag.commit) + } + _, _ = fmt.Fprint(w, "]") + default: + for _, tag := range tags { + if r.URL.Path == fmt.Sprintf("/repos/k0sproject/k0s/commits/%s", tag.commit) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, "{\"commit\":{\"committer\":{\"date\":\"%s\"}}}", tag.date) + return + } + } + http.NotFound(w, r) + } + })) + + t.Cleanup(server.Close) + return server +} + +func TestUpgradePathToStableTarget(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + server := setupTagServer(t) + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + current := mustVersion(t, "v1.24.1+k0s.0") + target := mustVersion(t, "v1.26.1+k0s.0") + + path, err := current.UpgradePath(target) + if err != nil { + t.Fatalf("UpgradePath returned error: %v", err) + } + + got := versionsToStrings(path) + want := []string{"v1.24.3+k0s.0", "v1.25.1+k0s.0", "v1.26.1+k0s.0"} + if len(got) != len(want) { + t.Fatalf("expected %d steps, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected step %d: got %q want %q", i, got[i], want[i]) + } + } +} + +func TestUpgradePathToPrereleaseTarget(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + server := setupTagServer(t) + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + current := mustVersion(t, "v1.24.1+k0s.0") + target := mustVersion(t, "v1.26.0-rc.1+k0s.0") + + path, err := current.UpgradePath(target) + if err != nil { + t.Fatalf("UpgradePath returned error: %v", err) + } + + got := versionsToStrings(path) + want := []string{"v1.24.3+k0s.0", "v1.25.1+k0s.0", "v1.26.0-rc.1+k0s.0"} + if len(got) != len(want) { + t.Fatalf("expected %d steps, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected step %d: got %q want %q", i, got[i], want[i]) + } + } +} + +func TestUpgradePathRejectsDowngrade(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + server := setupTagServer(t) + t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_TOKEN", "") + + current := mustVersion(t, "v1.25.0+k0s.0") + target := mustVersion(t, "v1.24.3+k0s.0") + + if _, err := current.UpgradePath(target); err == nil { + t.Fatal("expected downgrade error") + } +} + +func versionsToStrings(collection Collection) []string { + out := make([]string, 0, len(collection)) + for _, v := range collection { + if v == nil { + continue + } + out = append(out, v.String()) + } + return out +}