From d1d9394678dfccd548445f6df3bc7d5b5d6f957b Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Mon, 13 Oct 2025 17:40:34 +0300 Subject: [PATCH 1/3] 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 70c575450b7738d3758389ecf4d54c12e795fec0 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Mon, 13 Oct 2025 18:11:41 +0300 Subject: [PATCH 2/3] Cleanup Signed-off-by: Kimmo Lehto --- collection.go | 4 ++-- http_client.go | 18 ++++++++++++++++++ internal/github/client.go | 4 +++- internal/httpclient/httpclient.go | 23 +++++++++++++++++++++++ latest.go | 14 ++++---------- 5 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 http_client.go create mode 100644 internal/httpclient/httpclient.go diff --git a/collection.go b/collection.go index 8930265..04167bc 100644 --- a/collection.go +++ b/collection.go @@ -122,7 +122,7 @@ func (c Collection) writeCache() error { // 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) + result, err := loadAll(ctx, defaultHTTPClient(), false) return result.versions, err } @@ -134,7 +134,7 @@ func Refresh() (Collection, error) { // 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) + result, err := loadAll(ctx, defaultHTTPClient(), true) return result.versions, err } diff --git a/http_client.go b/http_client.go new file mode 100644 index 0000000..19e122f --- /dev/null +++ b/http_client.go @@ -0,0 +1,18 @@ +package version + +import ( + "net/http" + + "github.com/k0sproject/version/internal/httpclient" +) + +// Timeout controls the default HTTP client timeout for remote lookups. +var Timeout = httpclient.DefaultTimeout + +func defaultHTTPClient() *http.Client { + return httpclient.New(Timeout) +} + +func docsHTTPClient() *http.Client { + return httpclient.New(Timeout) +} diff --git a/internal/github/client.go b/internal/github/client.go index 72e6695..f3eb0ea 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/k0sproject/version/internal/httpclient" ) const ( @@ -35,7 +37,7 @@ type Client struct { // 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} + httpClient = httpclient.New(httpclient.DefaultTimeout) } base := os.Getenv("GITHUB_API_URL") diff --git a/internal/httpclient/httpclient.go b/internal/httpclient/httpclient.go new file mode 100644 index 0000000..5520685 --- /dev/null +++ b/internal/httpclient/httpclient.go @@ -0,0 +1,23 @@ +package httpclient + +import ( + "net/http" + "time" +) + +const DefaultTimeout = 10 * time.Second + +// New returns a fresh HTTP client configured with the provided timeout. Callers +// should supply the desired timeout while relying on the standard library to +// manage connection pooling for the lifetime of the client. +func New(timeout time.Duration) *http.Client { + timeout = normalizeTimeout(timeout) + return &http.Client{Timeout: timeout} +} + +func normalizeTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return DefaultTimeout + } + return timeout +} diff --git a/latest.go b/latest.go index 4b6f30e..543fd3c 100644 --- a/latest.go +++ b/latest.go @@ -8,13 +8,6 @@ import ( "net/url" "os" "strings" - "time" -) - -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. When allowpre is @@ -26,7 +19,8 @@ func LatestByPrerelease(allowpre bool) (*Version, error) { // 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) + client := defaultHTTPClient() + result, err := loadAll(ctx, client, false) versions := result.versions var candidate *Version @@ -37,7 +31,7 @@ func LatestByPrereleaseContext(ctx context.Context, allowpre bool) (*Version, er } } - fallback, fallbackErr := fetchLatestFromDocs(ctx, sharedHTTPClient, allowpre) + fallback, fallbackErr := fetchLatestFromDocs(ctx, docsHTTPClient(), allowpre) if fallbackErr == nil { if candidate == nil { return fallback, nil @@ -122,7 +116,7 @@ func fetchLatestFromDocs(ctx context.Context, client *http.Client, allowpre bool func httpGet(ctx context.Context, client *http.Client, u string) (string, error) { if client == nil { - client = sharedHTTPClient + client = defaultHTTPClient() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) From 1191c6ed5bc8284e167f0bf77970a5404860b9e5 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 17 Oct 2025 15:01:11 +0300 Subject: [PATCH 3/3] Cleanup2 Signed-off-by: Kimmo Lehto --- collection.go | 16 +- http_client.go | 250 +++++++++++++++++++++++++++++- http_client_test.go | 190 +++++++++++++++++++++++ internal/github/client.go | 34 +++- internal/httpclient/httpclient.go | 23 --- latest.go | 63 ++++---- latest_test.go | 19 ++- 7 files changed, 517 insertions(+), 78 deletions(-) create mode 100644 http_client_test.go delete mode 100644 internal/httpclient/httpclient.go diff --git a/collection.go b/collection.go index 04167bc..8cde35f 100644 --- a/collection.go +++ b/collection.go @@ -122,7 +122,12 @@ func (c Collection) writeCache() error { // 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, defaultHTTPClient(), false) + client := httpClientFromContext(ctx, httpClientKeyGitHub, defaultHTTPClient) + ctxWithTimeout, cancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if cancel != nil { + defer cancel() + } + result, err := loadAll(ctxWithTimeout, client, false) return result.versions, err } @@ -134,7 +139,12 @@ func Refresh() (Collection, error) { // 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, defaultHTTPClient(), true) + client := httpClientFromContext(ctx, httpClientKeyGitHub, defaultHTTPClient) + ctxWithTimeout, cancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if cancel != nil { + defer cancel() + } + result, err := loadAll(ctxWithTimeout, client, true) return result.versions, err } @@ -162,7 +172,7 @@ func loadAll(ctx context.Context, httpClient *http.Client, force bool) (loadResu return loadResult{versions: collectionFromMap(known)}, nil } - client := github.NewClient(httpClient) + client := github.NewClientWithBaseURL(httpClient, githubAPIURL()) tags, err := client.TagsSince(ctx, modTime) if err != nil { if force || len(known) == 0 { diff --git a/http_client.go b/http_client.go index 19e122f..ec69968 100644 --- a/http_client.go +++ b/http_client.go @@ -1,18 +1,258 @@ package version import ( + "context" + "net" "net/http" + "os" + "strings" + "sync" + "time" +) + +const ( + defaultGitHubAPIBaseURL = "https://api.github.com" + defaultDocsBaseURL = "https://docs.k0sproject.io" +) + +var ( + // GitHubAPIURL controls the base URL used when querying the GitHub API. + // When empty, the value is resolved from the GITHUB_API_URL environment + // variable and ultimately falls back to the public api.github.com endpoint. + GitHubAPIURL = strings.TrimSpace(os.Getenv("GITHUB_API_URL")) + + // LatestURL overrides the URL used to fetch the latest version reference + // that may include prereleases. When empty, the value is derived from + // K0S_VERSION_DOCS_BASE_URL or the public docs endpoint. + LatestURL string + + // StableURL overrides the URL used to fetch the stable version reference. + // When empty, the value is derived from K0S_VERSION_DOCS_BASE_URL or the + // public docs endpoint. + StableURL string + + // HTTPTimeout bounds the overall duration of HTTP requests issued by the + // package level helpers. A non-positive value disables the client timeout. + HTTPTimeout = 10 * time.Second + + // HTTPConnectTimeout bounds how long a dial attempt is allowed to take. + // A non-positive value allows connections to use the net/http defaults. + HTTPConnectTimeout = 5 * time.Second + + // HTTPReadTimeout bounds how long the client waits for response headers. + // A non-positive value keeps the net/http defaults. + HTTPReadTimeout = 5 * time.Second + + defaultClientMu sync.RWMutex + defaultClient *http.Client + + docsClientMu sync.RWMutex + docsClient *http.Client +) + +type httpClientTarget uint8 + +const ( + httpClientTargetGitHub httpClientTarget = iota + httpClientTargetDocs +) + +type httpClientContextKey struct { + target httpClientTarget +} - "github.com/k0sproject/version/internal/httpclient" +type httpTimeoutKey struct { + target httpClientTarget +} + +var ( + httpClientKeyGitHub = httpClientContextKey{target: httpClientTargetGitHub} + httpClientKeyDocs = httpClientContextKey{target: httpClientTargetDocs} + + httpTimeoutKeyGitHub = httpTimeoutKey{target: httpClientTargetGitHub} + httpTimeoutKeyDocs = httpTimeoutKey{target: httpClientTargetDocs} ) -// Timeout controls the default HTTP client timeout for remote lookups. -var Timeout = httpclient.DefaultTimeout +// ContextWithHTTPClient returns a context configured to use client for GitHub lookups. +func ContextWithHTTPClient(ctx context.Context, client *http.Client) context.Context { + return withHTTPClient(ctx, httpClientKeyGitHub, client) +} + +// ContextWithDocsHTTPClient returns a context configured to use client for documentation lookups. +func ContextWithDocsHTTPClient(ctx context.Context, client *http.Client) context.Context { + return withHTTPClient(ctx, httpClientKeyDocs, client) +} + +// ContextWithHTTPTimeout returns a context configured with a per-request timeout for GitHub lookups. +// A non-positive timeout disables the default and keeps the existing deadline behavior. +func ContextWithHTTPTimeout(ctx context.Context, timeout time.Duration) context.Context { + return withHTTPTimeout(ctx, httpTimeoutKeyGitHub, timeout) +} + +// ContextWithDocsHTTPTimeout returns a context configured with a per-request timeout for documentation lookups. +// A non-positive timeout disables the default and keeps the existing deadline behavior. +func ContextWithDocsHTTPTimeout(ctx context.Context, timeout time.Duration) context.Context { + return withHTTPTimeout(ctx, httpTimeoutKeyDocs, timeout) +} + +func withHTTPClient(ctx context.Context, key httpClientContextKey, client *http.Client) context.Context { + if ctx == nil { + ctx = context.Background() + } + if client == nil { + return ctx + } + return context.WithValue(ctx, key, client) +} + +func httpClientFromContext(ctx context.Context, key httpClientContextKey, fallback func() *http.Client) *http.Client { + if ctx != nil { + if client, ok := ctx.Value(key).(*http.Client); ok && client != nil { + return client + } + } + return fallback() +} + +func withHTTPTimeout(ctx context.Context, key httpTimeoutKey, timeout time.Duration) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, key, timeout) +} + +func timeoutFromContext(ctx context.Context, key httpTimeoutKey) (time.Duration, bool) { + if ctx == nil { + return 0, false + } + val, ok := ctx.Value(key).(time.Duration) + return val, ok +} + +func withTargetTimeout(ctx context.Context, key httpTimeoutKey) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + if _, ok := ctx.Deadline(); ok { + return ctx, nil + } + + if timeout, ok := timeoutFromContext(ctx, key); ok { + if timeout <= 0 { + return ctx, nil + } + return context.WithTimeout(ctx, timeout) + } + + if HTTPTimeout > 0 { + return context.WithTimeout(ctx, HTTPTimeout) + } + return ctx, nil +} func defaultHTTPClient() *http.Client { - return httpclient.New(Timeout) + defaultClientMu.RLock() + client := defaultClient + defaultClientMu.RUnlock() + if client != nil { + return client + } + + defaultClientMu.Lock() + defer defaultClientMu.Unlock() + if defaultClient == nil { + defaultClient = newConfiguredHTTPClient() + } + return defaultClient } func docsHTTPClient() *http.Client { - return httpclient.New(Timeout) + docsClientMu.RLock() + client := docsClient + docsClientMu.RUnlock() + if client != nil { + return client + } + + docsClientMu.Lock() + defer docsClientMu.Unlock() + if docsClient == nil { + docsClient = newConfiguredHTTPClient() + } + return docsClient +} + +// SetDefaultHTTPClient overrides the shared HTTP client used for GitHub lookups by helper functions without contexts. +func SetDefaultHTTPClient(client *http.Client) { + defaultClientMu.Lock() + defaultClient = client + defaultClientMu.Unlock() +} + +// SetDocsHTTPClient overrides the shared HTTP client used for documentation fallbacks. +func SetDocsHTTPClient(client *http.Client) { + docsClientMu.Lock() + docsClient = client + docsClientMu.Unlock() +} + +func newConfiguredHTTPClient() *http.Client { + baseTransport, _ := http.DefaultTransport.(*http.Transport) + var transport *http.Transport + if baseTransport != nil { + transport = baseTransport.Clone() + } else { + transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + } + } + + if HTTPConnectTimeout > 0 { + dialer := &net.Dialer{ + Timeout: HTTPConnectTimeout, + KeepAlive: 30 * time.Second, + } + transport.DialContext = dialer.DialContext + transport.TLSHandshakeTimeout = HTTPConnectTimeout + } + + if HTTPReadTimeout > 0 { + transport.ResponseHeaderTimeout = HTTPReadTimeout + } + + client := &http.Client{Transport: transport} + if HTTPTimeout > 0 { + client.Timeout = HTTPTimeout + } + return client +} + +func githubAPIURL() string { + if base := strings.TrimSpace(GitHubAPIURL); base != "" { + return strings.TrimRight(base, "/") + } + if base := strings.TrimSpace(os.Getenv("GITHUB_API_URL")); base != "" { + return strings.TrimRight(base, "/") + } + return defaultGitHubAPIBaseURL +} + +func docsURL(includePrerelease bool) string { + if includePrerelease { + if u := strings.TrimSpace(LatestURL); u != "" { + return u + } + return docsBaseURL() + "/latest.txt" + } + if u := strings.TrimSpace(StableURL); u != "" { + return u + } + return docsBaseURL() + "/stable.txt" +} + +func docsBaseURL() string { + if base := strings.TrimSpace(os.Getenv("K0S_VERSION_DOCS_BASE_URL")); base != "" { + return strings.TrimRight(base, "/") + } + return defaultDocsBaseURL } diff --git a/http_client_test.go b/http_client_test.go new file mode 100644 index 0000000..eed8efa --- /dev/null +++ b/http_client_test.go @@ -0,0 +1,190 @@ +package version + +import ( + "context" + "net/http" + "testing" + "time" +) + +func TestDefaultHTTPClientSingleton(t *testing.T) { + t.Helper() + + first := defaultHTTPClient() + second := defaultHTTPClient() + if first == nil { + t.Fatalf("expected non-nil default client") + } + if first != second { + t.Fatalf("expected default client to be reused") + } +} + +func TestDocsHTTPClientSingleton(t *testing.T) { + t.Helper() + + first := docsHTTPClient() + second := docsHTTPClient() + if first == nil { + t.Fatalf("expected non-nil docs client") + } + if first != second { + t.Fatalf("expected docs client to be reused") + } +} + +func TestContextWithHTTPClientOverride(t *testing.T) { + t.Helper() + + custom := &http.Client{} + ctx := ContextWithHTTPClient(context.Background(), custom) + got := httpClientFromContext(ctx, httpClientKeyGitHub, defaultHTTPClient) + if got != custom { + t.Fatalf("expected context override to be used") + } +} + +func TestContextWithDocsHTTPClientOverride(t *testing.T) { + t.Helper() + + custom := &http.Client{} + ctx := ContextWithDocsHTTPClient(context.Background(), custom) + got := httpClientFromContext(ctx, httpClientKeyDocs, docsHTTPClient) + if got != custom { + t.Fatalf("expected docs context override to be used") + } +} + +func TestContextWithHTTPClientNilFallsBack(t *testing.T) { + t.Helper() + + ctx := ContextWithHTTPClient(nil, nil) + def := defaultHTTPClient() + got := httpClientFromContext(ctx, httpClientKeyGitHub, defaultHTTPClient) + if got != def { + t.Fatalf("expected fallback client when override is nil") + } +} + +func TestSetDefaultHTTPClientOverride(t *testing.T) { + t.Helper() + + original := defaultHTTPClient() + override := &http.Client{} + SetDefaultHTTPClient(override) + t.Cleanup(func() { SetDefaultHTTPClient(original) }) + + got := defaultHTTPClient() + if got != override { + t.Fatalf("expected default HTTP client override to be used") + } +} + +func TestSetDocsHTTPClientOverride(t *testing.T) { + t.Helper() + + original := docsHTTPClient() + override := &http.Client{} + SetDocsHTTPClient(override) + t.Cleanup(func() { SetDocsHTTPClient(original) }) + + got := docsHTTPClient() + if got != override { + t.Fatalf("expected docs HTTP client override to be used") + } +} + +func TestWithTargetTimeoutDefault(t *testing.T) { + t.Helper() + + ctx := context.Background() + derived, cancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if cancel == nil { + t.Fatalf("expected cancel func when default timeout applies") + } + defer cancel() + if _, ok := derived.Deadline(); !ok { + t.Fatalf("expected default timeout to set deadline") + } +} + +func TestContextWithHTTPTimeoutOverride(t *testing.T) { + t.Helper() + + ctx := ContextWithHTTPTimeout(context.Background(), 2*time.Second) + derived, cancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if cancel == nil { + t.Fatalf("expected cancel func for override timeout") + } + defer cancel() + deadline, ok := derived.Deadline() + if !ok { + t.Fatalf("expected deadline when override timeout is set") + } + delta := time.Until(deadline) + if delta > 3*time.Second || delta < time.Second { + t.Fatalf("expected deadline roughly 2s away, got %v", delta) + } +} + +func TestContextWithHTTPTimeoutDisable(t *testing.T) { + t.Helper() + + ctx := ContextWithHTTPTimeout(context.Background(), 0) + derived, cancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if cancel != nil { + t.Fatalf("expected no cancel func when timeout disabled") + } + if _, ok := derived.Deadline(); ok { + t.Fatalf("expected no deadline when timeout disabled") + } +} + +func TestWithTargetTimeoutRespectsExistingDeadline(t *testing.T) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + derived, derivedCancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if derivedCancel != nil { + t.Fatalf("expected nil cancel when deadline already set") + } + deadline, ok := derived.Deadline() + if !ok { + t.Fatalf("expected existing deadline to remain") + } + if time.Until(deadline) <= 0 { + t.Fatalf("expected remaining time on deadline") + } +} + +func TestDefaultClientKeepsHandshakeTimeoutWhenConnectTimeoutDisabled(t *testing.T) { + t.Helper() + + originalConnect := HTTPConnectTimeout + originalClient := defaultHTTPClient() + SetDefaultHTTPClient(originalClient) + + originalTransport, _ := originalClient.Transport.(*http.Transport) + originalDefault := http.DefaultTransport.(*http.Transport).TLSHandshakeTimeout + if originalTransport != nil && originalTransport.TLSHandshakeTimeout == 0 && originalDefault == 0 { + t.Skip("default transport handshake timeout is zero; nothing to assert") + } + + SetDefaultHTTPClient(nil) + HTTPConnectTimeout = 0 + t.Cleanup(func() { + HTTPConnectTimeout = originalConnect + SetDefaultHTTPClient(originalClient) + }) + + client := defaultHTTPClient() + transport, _ := client.Transport.(*http.Transport) + if transport == nil { + t.Fatal("expected transport on default HTTP client") + } + + if got := transport.TLSHandshakeTimeout; got != originalDefault { + t.Fatalf("expected handshake timeout %v, got %v", originalDefault, got) + } +} diff --git a/internal/github/client.go b/internal/github/client.go index f3eb0ea..6a50510 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -12,8 +12,6 @@ import ( "strconv" "strings" "time" - - "github.com/k0sproject/version/internal/httpclient" ) const ( @@ -23,6 +21,7 @@ const ( perPage = 100 headerAccept = "application/vnd.github+json" headerUserAgent = "github.com/k0sproject/version" + defaultTimeout = 10 * time.Second ) // Client wraps GitHub REST usage tailored for listing tags. @@ -36,18 +35,28 @@ type Client struct { // 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 { + return NewClientWithBaseURL(httpClient, "") +} + +// NewClientWithBaseURL creates a GitHub client that targets baseURL when it is +// non-empty. When baseURL is empty the value is resolved from the environment +// and ultimately falls back to the public api.github.com endpoint. +func NewClientWithBaseURL(httpClient *http.Client, baseURL string) *Client { if httpClient == nil { - httpClient = httpclient.New(httpclient.DefaultTimeout) + httpClient = newHTTPClient(defaultTimeout) } - base := os.Getenv("GITHUB_API_URL") - if base == "" { - base = defaultBaseURL + resolved := strings.TrimSpace(baseURL) + if resolved == "" { + resolved = strings.TrimSpace(os.Getenv("GITHUB_API_URL")) + } + if resolved == "" { + resolved = defaultBaseURL } return &Client{ httpClient: httpClient, - baseURL: strings.TrimRight(base, "/"), + baseURL: strings.TrimRight(resolved, "/"), token: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")), } } @@ -145,3 +154,14 @@ func hasNextPage(linkHeader string) bool { type tagResponse struct { Name string `json:"name"` } + +func newHTTPClient(timeout time.Duration) *http.Client { + client := &http.Client{} + if timeout > 0 { + client.Timeout = timeout + } + if base, ok := http.DefaultTransport.(*http.Transport); ok && base != nil { + client.Transport = base.Clone() + } + return client +} diff --git a/internal/httpclient/httpclient.go b/internal/httpclient/httpclient.go deleted file mode 100644 index 5520685..0000000 --- a/internal/httpclient/httpclient.go +++ /dev/null @@ -1,23 +0,0 @@ -package httpclient - -import ( - "net/http" - "time" -) - -const DefaultTimeout = 10 * time.Second - -// New returns a fresh HTTP client configured with the provided timeout. Callers -// should supply the desired timeout while relying on the standard library to -// manage connection pooling for the lifetime of the client. -func New(timeout time.Duration) *http.Client { - timeout = normalizeTimeout(timeout) - return &http.Client{Timeout: timeout} -} - -func normalizeTimeout(timeout time.Duration) time.Duration { - if timeout <= 0 { - return DefaultTimeout - } - return timeout -} diff --git a/latest.go b/latest.go index 543fd3c..94618be 100644 --- a/latest.go +++ b/latest.go @@ -5,11 +5,20 @@ import ( "fmt" "io" "net/http" - "net/url" - "os" "strings" ) +// Latest returns the latest released k0s version, including prereleases. +func Latest() (*Version, error) { + return LatestByPrerelease(true) +} + +// LatestContext returns the latest released k0s version using the provided +// context, including prereleases. +func LatestContext(ctx context.Context) (*Version, error) { + return LatestByPrereleaseContext(ctx, true) +} + // LatestByPrerelease returns the latest released k0s version. When allowpre is // true prereleases are also accepted. func LatestByPrerelease(allowpre bool) (*Version, error) { @@ -19,8 +28,12 @@ func LatestByPrerelease(allowpre bool) (*Version, error) { // 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) { - client := defaultHTTPClient() - result, err := loadAll(ctx, client, false) + client := httpClientFromContext(ctx, httpClientKeyGitHub, defaultHTTPClient) + ctxWithTimeout, cancel := withTargetTimeout(ctx, httpTimeoutKeyGitHub) + if cancel != nil { + defer cancel() + } + result, err := loadAll(ctxWithTimeout, client, false) versions := result.versions var candidate *Version @@ -31,12 +44,14 @@ func LatestByPrereleaseContext(ctx context.Context, allowpre bool) (*Version, er } } - fallback, fallbackErr := fetchLatestFromDocs(ctx, docsHTTPClient(), allowpre) + docsClient := httpClientFromContext(ctx, httpClientKeyDocs, docsHTTPClient) + docsCtx, docsCancel := withTargetTimeout(ctx, httpTimeoutKeyDocs) + if docsCancel != nil { + defer docsCancel() + } + fallback, fallbackErr := fetchLatestFromDocs(docsCtx, docsClient, allowpre) if fallbackErr == nil { - if candidate == nil { - return fallback, nil - } - if fallback.GreaterThan(candidate) { + if candidate == nil || fallback.GreaterThan(candidate) { return fallback, nil } return candidate, nil @@ -63,18 +78,6 @@ 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) -} - -// 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] @@ -90,23 +93,9 @@ func selectLatest(collection Collection, allowpre bool) *Version { } 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 + target := docsURL(allowpre) - text, err := httpGet(ctx, client, baseURL.String()) + text, err := httpGet(ctx, client, target) if err != nil { return nil, err } diff --git a/latest_test.go b/latest_test.go index ce92cbb..35c2b37 100644 --- a/latest_test.go +++ b/latest_test.go @@ -1,6 +1,7 @@ package version_test import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -19,9 +20,9 @@ func TestLatestByPrerelease(t *testing.T) { 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"} - ]`) + {"name":"v1.25.0+k0s.0"}, + {"name":"v1.24.3+k0s.0"} + ]`) return } http.NotFound(w, r) @@ -63,4 +64,16 @@ func TestLatestByPrerelease(t *testing.T) { latest, err := version.LatestByPrerelease(true) NoError(t, err) Equal(t, "v1.26.0+k0s.0-rc.1", latest.String()) + + ctxLatest, err := version.LatestByPrereleaseContext(context.Background(), true) + NoError(t, err) + Equal(t, latest.String(), ctxLatest.String()) + + defaultLatest, err := version.Latest() + NoError(t, err) + Equal(t, latest.String(), defaultLatest.String()) + + ctxDefaultLatest, err := version.LatestContext(context.Background()) + NoError(t, err) + Equal(t, latest.String(), ctxDefaultLatest.String()) }