diff --git a/hack/bats/tests/url-github.bats b/hack/bats/tests/url-github.bats new file mode 100644 index 00000000000..b7b3163f9e9 --- /dev/null +++ b/hack/bats/tests/url-github.bats @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright The Lima Authors +# SPDX-License-Identifier: Apache-2.0 + +load "../helpers/load" + +# The jandubois/jandubois GitHub repo has been especially constructed to test +# various features of the github URL scheme: +# +# * repo defaults to org when not specified +# * filename defaults to .lima.yaml when only a path is specified +# * .yaml default extension +# * .lima.yaml files may be treated as symlinks +# * default branch lookup when not specified +# +# The repo files are: +# +# ├── .lima.yaml -> templates/demo.yaml +# ├── docs +# │ └── .lima.yaml -> ../templates/demo.yaml +# └── templates +# └── demo.yaml +# +# Both the `main` branch and the `v0.0.0` tag have this layout. + +# All these URLs should redirect to the same template URL (either on "main" or at "v0.0.0"): +# "https://raw.githubusercontent.com/jandubois/jandubois/${tag}/templates/demo.yaml" +URLS=( + github:jandubois/jandubois/templates/demo.yaml@main + github:jandubois/jandubois/templates/demo.yaml + github:jandubois/jandubois/templates/demo + github:jandubois/jandubois/.lima.yaml + github:jandubois/jandubois/@v0.0.0 + github:jandubois/jandubois + github:jandubois//templates/demo.yaml@main + github:jandubois//templates/demo.yaml + github:jandubois//templates/demo + github:jandubois//.lima.yaml + github:jandubois//@v0.0.0 + github:jandubois// + github:jandubois/ + github:jandubois@v0.0.0 + github:jandubois + github:jandubois/jandubois/docs/.lima.yaml@main + github:jandubois/jandubois/docs/.lima.yaml + github:jandubois/jandubois/docs/.lima + github:jandubois/jandubois/docs/ + github:jandubois//docs/.lima.yaml@v0.0.0 + github:jandubois//docs/.lima.yaml + github:jandubois//docs/.lima + github:jandubois//docs/@v0.0.0 + github:jandubois//docs/ +) + +url() { + run_e "$1" limactl template url "$2" +} + +test_jandubois_url() { + local url=$1 + local tag="main" + if [[ $url == *v0.0.0* ]]; then + tag="v0.0.0" + fi + + url -0 "$url" + assert_output "https://raw.githubusercontent.com/jandubois/jandubois/${tag}/templates/demo.yaml" +} + +# Dynamically register a @test for each URL in the list +for url in "${URLS[@]}"; do + bats_test_function --description "$url" -- test_jandubois_url "$url" +done + +@test '.lima.yaml is retained when it is not a symlink' { + url -0 'github:jandubois//test/' + assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.lima.yaml' +} + +@test 'hidden files without an extension get a .yaml extension' { + url -0 'github:jandubois//test/.hidden' + assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.hidden.yaml' +} + +@test 'files that have an extension do not get a .yaml extension' { + url -0 'github:jandubois//test/.script.sh' + assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.script.sh' +} + +@test 'github: URLs are EXPERIMENTAL' { + url -0 'github:jandubois' + assert_stderr --regexp 'warning.+GitHub locator .* replaced with .* EXPERIMENTAL' +} + +@test 'Empty github: url returns an error' { + url -1 'github:' + assert_fatal 'github: URL must contain at least an ORG, got ""' +} + +@test 'Missing org returns an error' { + url -1 'github:/jandubois' + assert_fatal 'github: URL must contain at least an ORG, got ""' +} diff --git a/pkg/limatmpl/github.go b/pkg/limatmpl/github.go new file mode 100644 index 00000000000..1a3191dc739 --- /dev/null +++ b/pkg/limatmpl/github.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package limatmpl + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "strings" +) + +// transformGitHubURL transforms a github: URL to a raw.githubusercontent.com URL. +// Input format: ORG/REPO[/PATH][@BRANCH] +// +// If REPO is missing, it will be set the same as ORG. +// If BRANCH is missing, it will be queried from GitHub. +// If PATH filename has no extension, it will get .yaml. +// If PATH is just a directory (trailing slash), it will be set to .lima.yaml +// IF FILE is .lima.yaml and contents looks like a symlink, it will be replaced by the symlink target. +func transformGitHubURL(ctx context.Context, input string) (string, error) { + // Check for explicit branch specification with @ at the end + var branch string + if idx := strings.LastIndex(input, "@"); idx != -1 { + branch = input[idx+1:] + input = input[:idx] + } + + parts := strings.Split(input, "/") + for len(parts) < 2 { + parts = append(parts, "") + } + + org := parts[0] + if org == "" { + return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input) + } + + // If REPO is omitted (github:ORG or github:ORG//PATH), default it to ORG + repo := cmp.Or(parts[1], org) + pathPart := strings.Join(parts[2:], "/") + + if pathPart == "" { + pathPart = ".lima.yaml" + } else { + // If path ends with /, it's a directory, so append .lima + if strings.HasSuffix(pathPart, "/") { + pathPart += ".lima" + } + + // If the filename (excluding first char for hidden files) has no extension, add .yaml + filename := path.Base(pathPart) + if !strings.Contains(filename[1:], ".") { + pathPart += ".yaml" + } + } + + // Query default branch if no branch was specified + if branch == "" { + var err error + branch, err = getGitHubDefaultBranch(ctx, org, repo) + if err != nil { + return "", fmt.Errorf("failed to get default branch for %s/%s: %w", org, repo, err) + } + } + + // If filename is .lima.yaml, check if it's a symlink/redirect to another file + if path.Base(pathPart) == ".lima.yaml" { + if redirectPath, err := resolveGitHubSymlink(ctx, org, repo, branch, pathPart); err == nil { + pathPart = redirectPath + } + } + + return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, pathPart), nil +} + +// getGitHubDefaultBranch queries the GitHub API to get the default branch for a repository. +func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", org, repo) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "lima") + req.Header.Set("Accept", "application/vnd.github.v3+json") + + // Check for GitHub token in environment for authenticated requests (higher rate limit) + token := cmp.Or(os.Getenv("GH_TOKEN"), os.Getenv("GITHUB_TOKEN")) + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to query GitHub API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read GitHub API response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var repoData struct { + DefaultBranch string `json:"default_branch"` + } + + if err := json.Unmarshal(body, &repoData); err != nil { + return "", fmt.Errorf("failed to parse GitHub API response: %w", err) + } + + if repoData.DefaultBranch == "" { + return "", fmt.Errorf("repository %s/%s has no default branch", org, repo) + } + + return repoData.DefaultBranch, nil +} + +// resolveGitHubSymlink checks if a file at the given path is a symlink/redirect to another file. +// If the file contains a single line without YAML content, it's treated as a path to the actual file. +// Returns the redirect path if found, or the original path otherwise. +func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath string) (string, error) { + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "lima") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("file not found or inaccessible: status %d", resp.StatusCode) + } + + // Read first 1KB to check the file content + buf := make([]byte, 1024) + n, err := resp.Body.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("failed to read file content: %w", err) + } + content := string(buf[:n]) + + // A symlink must be a single line (without trailing newline), no spaces, no colons + if !(content == "" || strings.ContainsAny(content, "\n :")) { + // symlink is relative to the directory of filePath + return path.Join(path.Dir(filePath), content), nil + } + return filePath, nil +} diff --git a/pkg/limatmpl/locator.go b/pkg/limatmpl/locator.go index 5627b087dfd..d9a4e19f595 100644 --- a/pkg/limatmpl/locator.go +++ b/pkg/limatmpl/locator.go @@ -312,6 +312,15 @@ func TransformCustomURL(ctx context.Context, locator string) (string, error) { return newLocator, nil } + if u.Scheme == "github" { + newLocator, err := transformGitHubURL(ctx, u.Opaque) + if err != nil { + return "", err + } + logrus.Warnf("GitHub locator %q replaced with %q is still EXPERIMENTAL", locator, newLocator) + return newLocator, nil + } + plugin, err := plugins.Find("url-" + u.Scheme) if err != nil { return "", err