diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index f54d825bc1c..fde5181e559 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -288,6 +288,7 @@ gcs GENERALIZEDTIME getfattr getwindowid +gha ghp gitmodules gitrepo diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bad9b495784..83595b7556c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -200,3 +200,16 @@ updates: patterns: ["golang.org/x/*"] k8s: patterns: ["k8s.io/*"] + +- package-ecosystem: "gomod" + directory: "/scripts" + schedule: + interval: "daily" + open-pull-requests-limit: 12 + labels: ["component/dependencies"] + reviewers: ["jandubois"] + groups: + golang-x: + patterns: ["golang.org/x/*"] + k8s: + patterns: ["k8s.io/*"] diff --git a/.github/workflows/k3s-versions.yaml b/.github/workflows/k3s-versions.yaml new file mode 100644 index 00000000000..a16a628fb71 --- /dev/null +++ b/.github/workflows/k3s-versions.yaml @@ -0,0 +1,37 @@ +name: Update k3s-versions.json +on: + schedule: + - cron: '43 8 * * *' + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +jobs: + check-for-token: + outputs: + has-token: ${{ steps.calc.outputs.HAS_SECRET }} + runs-on: ubuntu-latest + steps: + - id: calc + run: echo "HAS_SECRET=${HAS_SECRET}" >> "${GITHUB_OUTPUT}" + env: + HAS_SECRET: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW != '' }} + + check-update-versions: + needs: check-for-token + if: needs.check-for-token.outputs.has-token == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # we may need to checkout an existing branch, so need the full history + fetch-depth: 0 + # Setup go to be able to run `go run ./scripts/k3s-version.go` + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version-file: go.work + - run: ./scripts/k3s-versions.sh + env: + GITHUB_TOKEN: ${{ secrets.RUN_WORKFLOW_FROM_WORKFLOW }} diff --git a/go.work b/go.work index 99584ceb3c3..3f9e7606ceb 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.23.0 toolchain go1.23.4 use ( + ./scripts ./src/go/docker-credential-none ./src/go/extension-proxy ./src/go/github-runner-monitor diff --git a/scripts/go.mod b/scripts/go.mod new file mode 100644 index 00000000000..a785970ed83 --- /dev/null +++ b/scripts/go.mod @@ -0,0 +1,5 @@ +module github.com/rancher-sandbox/rancher-desktop/scripts + +go 1.23 + +require golang.org/x/mod v0.22.0 diff --git a/scripts/go.sum b/scripts/go.sum new file mode 100644 index 00000000000..36066b89e4f --- /dev/null +++ b/scripts/go.sum @@ -0,0 +1,2 @@ +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= diff --git a/scripts/k3s-versions.go b/scripts/k3s-versions.go new file mode 100644 index 00000000000..bff078d9e0f --- /dev/null +++ b/scripts/k3s-versions.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "os" + "slices" + "strconv" + "strings" + + "golang.org/x/mod/semver" +) + +// golang.org/x/mod/semver *requires* a leading 'v' on versions, and will add missing minor/patch numbers. +const minimumVersion = "v1.21" + +type Channels struct { + Data []Channel `json:"data"` +} +type Channel struct { + Name string `json:"name"` + Latest string `json:"latest"` +} + +// getK3sChannels returns a map of all non-prerelease channels, plus "latest" and "stable". +// The values are the latest release for each channel. +func getK3sChannels() (map[string]string, error) { + resp, err := http.Get("https://update.k3s.io/v1-release/channels") + if err != nil { + return nil, fmt.Errorf("failed to get k3s channels: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("update channel request failed with status: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body for k3s update channel: %w", err) + } + + var channels Channels + if err := json.Unmarshal(body, &channels); err != nil { + return nil, fmt.Errorf("failed to unmarshal response from k3s update channel: %w", err) + } + + k3sChannels := make(map[string]string) + for _, channel := range channels.Data { + switch { + case channel.Name == "latest" || channel.Name == "stable": + break + case semver.Prerelease(channel.Latest) != "": + continue + case semver.IsValid(channel.Latest) && semver.Compare(channel.Latest, minimumVersion) >= 0: + break + default: + continue + } + // Turn "v1.31.3+k3s1" into "1.31.3" + latest := strings.TrimPrefix(channel.Latest, "v") + latest = strings.SplitN(latest, "+", 2)[0] + k3sChannels[channel.Name] = latest + } + + return k3sChannels, nil +} + +type GithubRelease struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +// getGithubReleasesPage fetches a single page of GitHub releases and returns a list +// of all non-draft, non-prerelease releases higher than the minimumVersion. +func getGithubReleasesPage(page int) ([]GithubRelease, error) { + url := fmt.Sprintf("https://api.github.com/repos/k3s-io/k3s/releases?page=%d", page) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for %q: %w", url, err) + } + token := os.Getenv("GH_TOKEN") + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request for %q: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + //nolint:revive // error-strings + return nil, fmt.Errorf("GitHub API request failed with status: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body for %q: %w", url, err) + } + + var releases []GithubRelease + if err := json.Unmarshal(body, &releases); err != nil { + return nil, fmt.Errorf("failed to unmarshal response for %q: %w", url, err) + } + + // Filter desired releases here, so caller will stop requesting additional pages if there are + // no more matches (heuristics, but releases are returned in reverse chronological order). + releases = slices.DeleteFunc(releases, func(release GithubRelease) bool { + return release.Draft || release.Prerelease || semver.Compare(release.TagName, minimumVersion) < 0 + }) + return releases, nil +} + +// getGithubReleases returns a sorted list of all matching GitHub releases. +func getGithubReleases() ([]string, error) { + releaseMap := make(map[string]string) + for page := 1; ; page++ { + releases, err := getGithubReleasesPage(page) + if err != nil { + return nil, err + } + if len(releases) == 0 { + break + } + for _, release := range releases { + version := semver.Canonical(release.TagName) + // for each version we only keep the latest k3s patch, i.e. +k3s2 instead of +k3s1 + if oldTag, ok := releaseMap[version]; ok { + oldPatch, _ := strconv.Atoi(strings.TrimPrefix(semver.Build(oldTag), "+k3s")) + patch, _ := strconv.Atoi(strings.TrimPrefix(semver.Build(release.TagName), "+k3s")) + if oldPatch > patch { + continue + } + } + releaseMap[version] = release.TagName + } + } + + return slices.SortedFunc(maps.Values(releaseMap), semver.Compare), nil +} + +func getK3sVersions() (string, error) { + k3sChannels, err := getK3sChannels() + if err != nil { + return "", fmt.Errorf("error fetching k3s channels: %w", err) + } + + githubReleases, err := getGithubReleases() + if err != nil { + return "", fmt.Errorf("error fetching GitHub releases: %w", err) + } + + result := map[string]interface{}{ + "cacheVersion": 2, + "channels": k3sChannels, + "versions": githubReleases, + } + + // json.Marshal will produce map keys in sort order + jsonResult, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", fmt.Errorf("error marshalling result to JSON: %w", err) + } + return string(jsonResult), nil +} + +func main() { + versions, err := getK3sVersions() + if err != nil { + panic(err) + } + + fmt.Println(versions) +} diff --git a/scripts/k3s-versions.sh b/scripts/k3s-versions.sh new file mode 100755 index 00000000000..fb6f4394dff --- /dev/null +++ b/scripts/k3s-versions.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# This script expects to be called from the root of the repo. +# It will rebuild resources/k3s-versions.json from both the k3s update +# channel and the GitHub k3s releases list. +# Creates a pull request if the new version is different. + +set -eu + +K3S_VERSIONS="resources/k3s-versions.json" +BRANCH_NAME="gha-update-k3s-versions" +NEW_PR="true" + +if git rev-parse --verify "origin/${BRANCH_NAME}" 2>/dev/null; then + # This logic relies on the fact that PR branches inside the repo get automatically + # deleted when the PR has been merged. We assume that if the branch exists, there + # is also a corresponding PR for it, so we just update the branch with a new commit. + git checkout "$BRANCH_NAME" + NEW_PR="false" +else + git checkout -b "$BRANCH_NAME" +fi + +go run ./scripts/k3s-versions.go "$MINIMUM_VERSION" >"$K3S_VERSIONS" + +# Exit if there are no changes +if git diff --exit-code; then + exit +fi + +export GIT_CONFIG_COUNT=2 +export GIT_CONFIG_KEY_0=user.name +export GIT_CONFIG_VALUE_0="Rancher Desktop GitHub Action" +export GIT_CONFIG_KEY_1=user.email +export GIT_CONFIG_VALUE_1="donotuse@rancherdesktop.io" + +git add "$K3S_VERSIONS" +git commit --signoff --message "Automated update: k3s-versions.json" +git push origin "$BRANCH_NAME" + +if [ "$NEW_PR" = "false" ]; then + exit +fi + +gh pr create \ + --title "Update k3s-versions.json" \ + --body "This pull request contains the latest update to k3s-versions.json." \ + --head "$BRANCH_NAME" \ + --base main diff --git a/src/go/networking/go.mod b/src/go/networking/go.mod index 6d8f52356d1..7f62c0972a5 100644 --- a/src/go/networking/go.mod +++ b/src/go/networking/go.mod @@ -44,7 +44,7 @@ require ( github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect golang.org/x/crypto v0.30.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.21.0 // indirect + golang.org/x/mod v0.22.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.26.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/src/go/networking/go.sum b/src/go/networking/go.sum index c1199b1cf4a..5ee126672ff 100644 --- a/src/go/networking/go.sum +++ b/src/go/networking/go.sum @@ -91,8 +91,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=