diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c2848..53f005d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,22 @@ jobs: exit 1 fi + # run unit tests + unit-tests: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run unit tests + run: go test -run '' builder/installer + # Build Job build: needs: verify-readme-files diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d117d95 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,94 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug kubectl installer (all defaults)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/features/src/kubectl", + "cwd": "${workspaceFolder}", + "args": [ + "-version=latest", + "-kubectxVersion=latest", + "-kubensVersion=latest", + "-k9sVersion=none", + "-helmVersion=none", + "-kustomizeVersion=none", + "-kubeconformVersion=none", + "-kubescoreVersion=none", + "-downloadUrl=", + "-kubectxDownloadUrl=", + "-kubensDownloadUrl=", + "-k9sDownloadUrl=", + "-helmDownloadUrl=", + "-kustomizeDownloadUrl=", + "-kubeconformDownloadUrl=", + "-kubescoreDownloadUrl=" + ] + }, + { + "name": "Debug kubectl installer (all defaults and token)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/features/src/kubectl", + "cwd": "${workspaceFolder}", + "args": [ + "-version=latest", + "-kubectxVersion=latest", + "-kubensVersion=latest", + "-k9sVersion=none", + "-helmVersion=none", + "-kustomizeVersion=none", + "-kubeconformVersion=none", + "-kubescoreVersion=none", + "-downloadUrl=", + "-kubectxDownloadUrl=", + "-kubensDownloadUrl=", + "-k9sDownloadUrl=", + "-helmDownloadUrl=", + "-kustomizeDownloadUrl=", + "-kubeconformDownloadUrl=", + "-kubescoreDownloadUrl=" + ], + "env": { + "DEV_FEATURE_TOKEN_GITHUB_API": "" + } + }, + { + "name": "Debug kubectl installer (pinned versions)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/features/src/kubectl", + "cwd": "${workspaceFolder}", + "args": [ + "-version=1.31.2", + "-kubectxVersion=0.9.5", + "-kubensVersion=0.9.5", + "-k9sVersion=0.32.5", + "-helmVersion=3.16.2", + "-kustomizeVersion=5.4.2", + "-kubeconformVersion=0.6.6", + "-kubescoreVersion=1.18.0", + "-downloadUrl=", + "-kubectxDownloadUrl=", + "-kubensDownloadUrl=", + "-k9sDownloadUrl=", + "-helmDownloadUrl=", + "-kustomizeDownloadUrl=", + "-kubeconformDownloadUrl=", + "-kubescoreDownloadUrl=" + ] + }, + { + "name": "Debug current Go file", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${file}", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/README.md b/README.md index f352b5e..48b896b 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,26 @@ and then run the installer: /data/kubectl.bin --version=1.35.0 ``` +If you are running this command inside a devcontainer (for example via docker-out), note that bind mount source paths are resolved by the Docker daemon host, not by the devcontainer filesystem. If the mounted folder looks empty in the second container, use a host-valid absolute path or use `docker cp` as a fallback. + +Quick troubleshooting check (replace `` with the path you want to mount): +```bash +docker run --rm -v :/data debian ls -la /data +``` + +Fallback example using `docker cp`: +```bash +cid=$(docker create -it debian /bin/bash) +docker cp ./kubectl.bin $cid:/data/kubectl.bin +docker start -ai $cid +``` + +Inside the container: +```bash +ls -l /data +/data/kubectl.bin --version=1.35.0 +``` + Alternatively, you can just pack a feature (use the tasks from `build/build.go`) and simply extract it into a `.devcontainer` folder and then use it from there as a local feature (`.my-feature {}`). Here is a one-liner that prepares the defined feature in this project: diff --git a/features/src/apm/README.md b/features/src/apm/README.md index 4eb7253..8147434 100755 --- a/features/src/apm/README.md +++ b/features/src/apm/README.md @@ -6,7 +6,7 @@ Installs APM (Agent Package Manager). ```json "features": { - "ghcr.io/postfinance/devcontainer-features/apm:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/apm:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/apm/devcontainer-feature.json b/features/src/apm/devcontainer-feature.json index 9d650b9..68c2da1 100644 --- a/features/src/apm/devcontainer-feature.json +++ b/features/src/apm/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "apm", - "version": "1.0.0", + "version": "1.1.0", "name": "APM (Agent Package Manager)", "description": "Installs APM (Agent Package Manager).", "options": { diff --git a/features/src/claude-code/README.md b/features/src/claude-code/README.md index d48c51c..3aeddc6 100644 --- a/features/src/claude-code/README.md +++ b/features/src/claude-code/README.md @@ -6,7 +6,7 @@ Installs Claude Code, Anthropic's AI coding assistant CLI. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/claude-code:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/claude-code:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/claude-code/devcontainer-feature.json b/features/src/claude-code/devcontainer-feature.json index 5f2c136..289a6d3 100644 --- a/features/src/claude-code/devcontainer-feature.json +++ b/features/src/claude-code/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "claude-code", - "version": "1.0.0", + "version": "1.1.0", "name": "claude-code", "description": "Installs Claude Code, Anthropic's AI coding assistant CLI.", "options": { diff --git a/features/src/docker-out/README.md b/features/src/docker-out/README.md index f5db838..911a7c3 100755 --- a/features/src/docker-out/README.md +++ b/features/src/docker-out/README.md @@ -6,7 +6,7 @@ Installs a Docker client which re-uses the host Docker socket. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/docker-out:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/docker-out:1.1.0": { "version": "latest", "composeVersion": "latest", "buildxVersion": "latest", diff --git a/features/src/docker-out/devcontainer-feature.json b/features/src/docker-out/devcontainer-feature.json index 2eec14a..b167a33 100644 --- a/features/src/docker-out/devcontainer-feature.json +++ b/features/src/docker-out/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "docker-out", - "version": "1.0.0", + "version": "1.1.0", "name": "Docker outside Docker", "description": "Installs a Docker client which re-uses the host Docker socket.", "options": { diff --git a/features/src/git-lfs/README.md b/features/src/git-lfs/README.md index 735e638..ec1b169 100755 --- a/features/src/git-lfs/README.md +++ b/features/src/git-lfs/README.md @@ -6,7 +6,7 @@ Installs Git LFS. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/git-lfs:1.0.1": { + "ghcr.io/postfinance/devcontainer-features/git-lfs:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/git-lfs/devcontainer-feature.json b/features/src/git-lfs/devcontainer-feature.json index cbe578b..c895a46 100644 --- a/features/src/git-lfs/devcontainer-feature.json +++ b/features/src/git-lfs/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "git-lfs", - "version": "1.0.1", + "version": "1.1.0", "name": "Git LFS", "description": "Installs Git LFS.", "options": { diff --git a/features/src/github-cli/README.md b/features/src/github-cli/README.md index b40db9c..5c9592f 100644 --- a/features/src/github-cli/README.md +++ b/features/src/github-cli/README.md @@ -6,7 +6,7 @@ Installs the GitHub CLI. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/github-cli:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/github-cli:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/github-cli/devcontainer-feature.json b/features/src/github-cli/devcontainer-feature.json index 17debb0..603b132 100644 --- a/features/src/github-cli/devcontainer-feature.json +++ b/features/src/github-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "github-cli", - "version": "1.0.0", + "version": "1.1.0", "name": "GitHub CLI", "description": "Installs the GitHub CLI.", "options": { diff --git a/features/src/github-copilot-cli/README.md b/features/src/github-copilot-cli/README.md index 93f560a..75e56e9 100755 --- a/features/src/github-copilot-cli/README.md +++ b/features/src/github-copilot-cli/README.md @@ -6,7 +6,7 @@ Installs GitHub Copilot CLI (copilot), the AI-powered coding assistant for the t ```json "features": { - "ghcr.io/postfinance/devcontainer-features/github-copilot-cli:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/github-copilot-cli:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/github-copilot-cli/devcontainer-feature.json b/features/src/github-copilot-cli/devcontainer-feature.json index b955250..f19d10c 100644 --- a/features/src/github-copilot-cli/devcontainer-feature.json +++ b/features/src/github-copilot-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "github-copilot-cli", - "version": "1.0.0", + "version": "1.1.0", "name": "GitHub Copilot CLI", "description": "Installs GitHub Copilot CLI (copilot), the AI-powered coding assistant for the terminal.", "options": { diff --git a/features/src/gonovate/README.md b/features/src/gonovate/README.md index c99f11a..f6a3f5f 100755 --- a/features/src/gonovate/README.md +++ b/features/src/gonovate/README.md @@ -6,7 +6,7 @@ Installs Gonovate. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/gonovate:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/gonovate:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/gonovate/devcontainer-feature.json b/features/src/gonovate/devcontainer-feature.json index 0195ef4..1b62b75 100644 --- a/features/src/gonovate/devcontainer-feature.json +++ b/features/src/gonovate/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "gonovate", - "version": "1.0.0", + "version": "1.1.0", "name": "Gonovate", "description": "Installs Gonovate.", "options": { diff --git a/features/src/goreleaser/README.md b/features/src/goreleaser/README.md index a948b55..100b54b 100755 --- a/features/src/goreleaser/README.md +++ b/features/src/goreleaser/README.md @@ -6,7 +6,7 @@ Installs GoReleaser. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/goreleaser:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/goreleaser:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/goreleaser/devcontainer-feature.json b/features/src/goreleaser/devcontainer-feature.json index 9da5b38..76fb238 100644 --- a/features/src/goreleaser/devcontainer-feature.json +++ b/features/src/goreleaser/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "goreleaser", - "version": "1.0.0", + "version": "1.1.0", "name": "GoReleaser", "description": "Installs GoReleaser.", "options": { diff --git a/features/src/kubectl/README.md b/features/src/kubectl/README.md index 80eba89..f3ada69 100755 --- a/features/src/kubectl/README.md +++ b/features/src/kubectl/README.md @@ -6,7 +6,7 @@ Installs kubectl and other tools for managing kubernetes. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/kubectl:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/kubectl:1.1.0": { "version": "latest", "kubectxVersion": "latest", "kubensVersion": "latest", diff --git a/features/src/kubectl/devcontainer-feature.json b/features/src/kubectl/devcontainer-feature.json index 049e96f..6c4c3f0 100644 --- a/features/src/kubectl/devcontainer-feature.json +++ b/features/src/kubectl/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "kubectl", - "version": "1.0.0", + "version": "1.1.0", "name": "kubectl", "description": "Installs kubectl and other tools for managing kubernetes.", "options": { diff --git a/features/src/opencode/README.md b/features/src/opencode/README.md index 655e3f9..74a57c0 100755 --- a/features/src/opencode/README.md +++ b/features/src/opencode/README.md @@ -6,7 +6,7 @@ Installs opencode, the open source AI coding agent. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/opencode:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/opencode:1.1.0": { "version": "latest", "downloadUrl": "" } diff --git a/features/src/opencode/devcontainer-feature.json b/features/src/opencode/devcontainer-feature.json index 0321203..9a6543b 100644 --- a/features/src/opencode/devcontainer-feature.json +++ b/features/src/opencode/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "opencode", - "version": "1.0.0", + "version": "1.1.0", "name": "opencode", "description": "Installs opencode, the open source AI coding agent.", "options": { diff --git a/features/src/python/README.md b/features/src/python/README.md index 2f8f086..3a92b67 100755 --- a/features/src/python/README.md +++ b/features/src/python/README.md @@ -6,7 +6,7 @@ Installs Python. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/python:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/python:1.1.0": { "version": "latest", "downloadUrl": "", "pipIndex": "", diff --git a/features/src/python/devcontainer-feature.json b/features/src/python/devcontainer-feature.json index 16c2972..1908836 100644 --- a/features/src/python/devcontainer-feature.json +++ b/features/src/python/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "python", - "version": "1.0.0", + "version": "1.1.0", "name": "Python", "description": "Installs Python.", "options": { diff --git a/features/src/rust/README.md b/features/src/rust/README.md index f769ff0..83b6d08 100755 --- a/features/src/rust/README.md +++ b/features/src/rust/README.md @@ -6,7 +6,7 @@ A package which installs Rust, common Rust utilities and their required dependen ```json "features": { - "ghcr.io/postfinance/devcontainer-features/rust:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/rust:1.1.0": { "version": "latest", "rustupVersion": "latest", "profile": "minimal", diff --git a/features/src/rust/devcontainer-feature.json b/features/src/rust/devcontainer-feature.json index 8591f15..6b9b895 100644 --- a/features/src/rust/devcontainer-feature.json +++ b/features/src/rust/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "rust", - "version": "1.0.0", + "version": "1.1.0", "name": "Rust", "description": "A package which installs Rust, common Rust utilities and their required dependencies.", "options": { diff --git a/features/src/sonar-scanner-cli/README.md b/features/src/sonar-scanner-cli/README.md index b36cf75..52404d5 100755 --- a/features/src/sonar-scanner-cli/README.md +++ b/features/src/sonar-scanner-cli/README.md @@ -6,7 +6,7 @@ Installs the SonarScanner CLI. ```json "features": { - "ghcr.io/postfinance/devcontainer-features/sonar-scanner-cli:1.0.0": { + "ghcr.io/postfinance/devcontainer-features/sonar-scanner-cli:1.1.0": { "version": "latest", "includeJre": true, "downloadUrl": "" diff --git a/features/src/sonar-scanner-cli/devcontainer-feature.json b/features/src/sonar-scanner-cli/devcontainer-feature.json index f40570c..2f45e59 100644 --- a/features/src/sonar-scanner-cli/devcontainer-feature.json +++ b/features/src/sonar-scanner-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "sonar-scanner-cli", - "version": "1.0.0", + "version": "1.1.0", "name": "SonarScanner CLI", "description": "Installs the SonarScanner CLI.", "options": { diff --git a/installer/github.go b/installer/github.go index a9a8129..880cfad 100644 --- a/installer/github.go +++ b/installer/github.go @@ -1,17 +1,60 @@ package installer import ( + "bufio" "encoding/json" + "errors" "fmt" "io" "net/http" "os" "regexp" + "strings" + + "github.com/roemer/goext" ) +const headerRateLimitRemaining = "x-ratelimit-remaining" + type gitHub struct{} func (g *gitHub) GetTags(owner string, repo string) ([]string, error) { + // fetching tags via GitHub API... + tags, err := g.fetchTagsViaGithubAPI(owner, repo) + if err == nil { + // ...was successful -> return the tags + return tags, nil + } + + // ...failed + var rateLimitErr *GitHubRateLimitError + // check if err is a GitHubRateLimitError + if ok := errors.As(err, &rateLimitErr); ok { + // err is a GitHubRateLimitError + // should we use the fallback? + if rateLimitErr.UseFallback { + // yes + return g.fetchTagsViaGitLsRemote(owner, repo) + } else { + // no, return the error + return nil, err + } + } + + // if the error is not a GitHubRateLimitError, we will return the error + return nil, err +} + +type GitHubRateLimitError struct { + UseFallback bool + Message string +} + +func (e *GitHubRateLimitError) Error() string { + return fmt.Sprintf("GitHub API rate limit error: %s", e.Message) +} + +func (g *gitHub) fetchTagsViaGithubAPI(owner string, repo string) ([]string, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags?per_page=100", owner, repo) nextRegexp := regexp.MustCompile(`(?i)<([^<]*)>; rel="next"`) var tagNames []string @@ -33,7 +76,7 @@ func (g *gitHub) GetTags(owner string, repo string) ([]string, error) { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to download file '%s'. Status code: %d", url, resp.StatusCode) + return g.handleRateLimitError(resp, url, apiToken) } bodyBytes, err := io.ReadAll(resp.Body) if err != nil { @@ -65,3 +108,64 @@ func (g *gitHub) GetTags(owner string, repo string) ([]string, error) { // Return the found items return tagNames, nil } + +func (g *gitHub) fetchTagsViaGitLsRemote(owner, repo string) ([]string, error) { + gitTagsStdout, gitTagsStderr, err := goext.CmdRunners.Default.RunGetOutput("git", "ls-remote", "--tags", fmt.Sprintf("https://github.com/%s/%s.git", owner, repo)) + if err != nil { + return nil, fmt.Errorf("failed getting git tags: %v - %s", err, gitTagsStderr) + } + + lineRegexp := regexp.MustCompile(`^[a-fA-F0-9]+\s+refs/tags/(.*)$`) + var tagNames []string + scanner := bufio.NewScanner(strings.NewReader(gitTagsStdout)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasSuffix(line, "^{}") { + continue + } + if matches := lineRegexp.FindStringSubmatch(line); matches != nil { + tagName := matches[1] + tagNames = append(tagNames, tagName) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed processing the line scanner: %w", err) + } + + return tagNames, nil +} + +func (g *gitHub) handleRateLimitError(resp *http.Response, url, apiToken string) ([]string, error) { + if !g.isRateLimit(resp) { + return nil, fmt.Errorf("failed to download file '%s'. Status code: %d", url, resp.StatusCode) + } + + hasToken := apiToken != "" + if !hasToken { + return nil, &GitHubRateLimitError{ + Message: fmt.Sprintf( + "failed to download file '%s'. Status code: %d. You may have reached the GitHub API rate limit. Consider setting the DEV_FEATURE_TOKEN_GITHUB_API environment variable with a personal access token to increase the limit.", + url, resp.StatusCode, + ), + UseFallback: false, + } + } + + return nil, &GitHubRateLimitError{ + Message: fmt.Sprintf( + "failed to download file '%s'. Status code: %d. You may have reached the GitHub API rate limit even with a personal access token. A fallback via git ls-remote is attempted...", + url, resp.StatusCode, + ), + UseFallback: true, + } +} + +func (g *gitHub) isRateLimit(resp *http.Response) bool { + // rate limit rules: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10 + // the method net/http.Header.Get() already canonicalize the header name, so we can use it directly + if (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests) && resp.Header.Get(headerRateLimitRemaining) == "0" { + return true + } + return false +} diff --git a/installer/github_test.go b/installer/github_test.go new file mode 100644 index 0000000..49ba2f2 --- /dev/null +++ b/installer/github_test.go @@ -0,0 +1,152 @@ +package installer + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type myFakeService func(req *http.Request) (*http.Response, error) + +func (f myFakeService) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func mockDefaultHTTPClient(t *testing.T, rt myFakeService) { + t.Helper() + oldClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: rt} + t.Cleanup(func() { + http.DefaultClient = oldClient + }) +} + +func TestFetchTagsViaGithubAPIWithoutTokenSuccess(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("DEV_FEATURE_TOKEN_GITHUB_API", "") + + var authorizationHeader string + mockDefaultHTTPClient(t, func(req *http.Request) (*http.Response, error) { + authorizationHeader = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[{"name":"v1.0.0"},{"name":"v1.1.0"}]`)), + }, nil + }) + + g := &gitHub{} + tags, err := g.fetchTagsViaGithubAPI("owner", "repo") + require.NoError(t, err) + assert.Empty(t, authorizationHeader) + assert.Contains(t, tags, "v1.1.0") +} + +func TestFetchTagsViaGithubAPIWithTokenSuccess(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("DEV_FEATURE_TOKEN_GITHUB_API", "ghp_dummy_token") + + var authorizationHeader string + mockDefaultHTTPClient(t, func(req *http.Request) (*http.Response, error) { + authorizationHeader = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[{"name":"v2.0.0"},{"name":"v2.1.0"}]`)), + }, nil + }) + + g := &gitHub{} + tags, err := g.fetchTagsViaGithubAPI("owner", "repo") + require.NoError(t, err) + assert.Equal(t, "Bearer ghp_dummy_token", authorizationHeader) + assert.Contains(t, tags, "v2.1.0") +} + +func TestFetchTagsViaGitLsRemoteSuccess(t *testing.T) { + t.Chdir(t.TempDir()) + + tmpDir := t.TempDir() + fakeGitPath := filepath.Join(tmpDir, "git") + err := os.WriteFile(fakeGitPath, []byte(`#!/usr/bin/env sh +if [ "$1" = "ls-remote" ] && [ "$2" = "--tags" ]; then + cat <<'EOF' +1111111111111111111111111111111111111111 refs/tags/v0.1.0 +1111111111111111111111111111111111111111 refs/tags/v0.1.0^{} +2222222222222222222222222222222222222222 refs/tags/v0.2.0 +EOF + exit 0 +fi +echo "unexpected args: $@" >&2 +exit 1 +`), 0755) + require.NoError(t, err) + + path := os.Getenv("PATH") + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+path) + + g := &gitHub{} + tags, err := g.fetchTagsViaGitLsRemote("owner", "repo") + require.NoError(t, err) + assert.Contains(t, tags, "v0.1.0") + assert.Contains(t, tags, "v0.2.0") + assert.Len(t, tags, 2) +} + +func TestFetchTagsViaGithubAPIWithoutTokenFailed(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("DEV_FEATURE_TOKEN_GITHUB_API", "") + + mockDefaultHTTPClient(t, func(req *http.Request) (*http.Response, error) { + h := make(http.Header) + h.Set(headerRateLimitRemaining, "0") + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: h, + Body: io.NopCloser(strings.NewReader(`{"message":"API rate limit exceeded"}`)), + }, nil + }) + + g := &gitHub{} + tags, err := g.fetchTagsViaGithubAPI("owner", "repo") + require.Error(t, err) + assert.Nil(t, tags) + + var rateLimitErr *GitHubRateLimitError + require.ErrorAs(t, err, &rateLimitErr) + assert.False(t, rateLimitErr.UseFallback) + assert.Contains(t, err.Error(), "You may have reached the GitHub API rate limit") + assert.Contains(t, err.Error(), "DEV_FEATURE_TOKEN_GITHUB_API") +} + +func TestFetchTagsViaGithubAPIWithTokenFailed(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("DEV_FEATURE_TOKEN_GITHUB_API", "ghp_dummy_token") + + mockDefaultHTTPClient(t, func(req *http.Request) (*http.Response, error) { + h := make(http.Header) + h.Set(headerRateLimitRemaining, "0") + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: h, + Body: io.NopCloser(strings.NewReader(`{"message":"API rate limit exceeded"}`)), + }, nil + }) + + g := &gitHub{} + tags, err := g.fetchTagsViaGithubAPI("owner", "repo") + require.Error(t, err) + assert.Nil(t, tags) + + var rateLimitErr *GitHubRateLimitError + require.ErrorAs(t, err, &rateLimitErr) + assert.True(t, rateLimitErr.UseFallback) + assert.Contains(t, err.Error(), "You may have reached the GitHub API rate limit even with a personal access token") + assert.Contains(t, err.Error(), "A fallback via git ls-remote is attempted") +} diff --git a/installer/overrides_test.go b/installer/overrides_test.go index 73551a5..b0efc8a 100644 --- a/installer/overrides_test.go +++ b/installer/overrides_test.go @@ -22,6 +22,7 @@ func TestOverrideUseEnv(t *testing.T) { var testValue string os.Setenv("DEV_FEATURE_OVERRIDE_TEST_KEY", "override") + defer os.Unsetenv("DEV_FEATURE_OVERRIDE_TEST_KEY") HandleOverride(&testValue, "default", "test-key") assert.Equal("override", testValue) } @@ -40,9 +41,11 @@ func TestOverrideUseFile(t *testing.T) { // Manually set an env value beforehand os.Setenv("DEV_FEATURE_OVERRIDE_TEST_KEY_SET", "no-override") + defer os.Unsetenv("DEV_FEATURE_OVERRIDE_TEST_KEY_SET") // Load the overrides of the file os.Setenv("DEV_FEATURE_OVERRIDE_LOCATION", server.URL+"/file") + defer os.Unsetenv("DEV_FEATURE_OVERRIDE_LOCATION") loadErr := LoadOverrides() assert.NoError(loadErr, "LoadOverrides should not return an error")