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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,46 @@ 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.

### 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.
Expand All @@ -131,4 +171,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)
[![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)
201 changes: 201 additions & 0 deletions collection.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading