Skip to content
Merged
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
102 changes: 102 additions & 0 deletions hack/bats/tests/url-github.bats
Original file line number Diff line number Diff line change
@@ -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:[email protected]
github:jandubois
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to cover in this PR, but maybe we can even further shorten this form to gh:jandubois

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could shorten it, but template: is kind of long too, so github: fits right in, and maybe gh: is a bit obscure for some people.

Or did you want gh: as an alias, and not as a replacement? Not sure if that is good, as people might wonder what the difference would be.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an alias. No need to cover in this PR anyway.

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/[email protected]
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 ""'
}
168 changes: 168 additions & 0 deletions pkg/limatmpl/github.go
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to add unit tests for functions in the file?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally had unit tests, but replaced them with the BATS (integration) tests because some of them require network access.

I don't see any benefit to splitting the tests up, and find the BATS tests more concise anyways.

Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions pkg/limatmpl/locator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down