Skip to content

Commit 8944d8b

Browse files
authored
Merge pull request #4134 from jandubois/url-github
Implement custom `github:` URL scheme in Go
2 parents d7e3bd4 + 4faf738 commit 8944d8b

File tree

3 files changed

+279
-0
lines changed

3 files changed

+279
-0
lines changed

hack/bats/tests/url-github.bats

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# SPDX-FileCopyrightText: Copyright The Lima Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
load "../helpers/load"
5+
6+
# The jandubois/jandubois GitHub repo has been especially constructed to test
7+
# various features of the github URL scheme:
8+
#
9+
# * repo defaults to org when not specified
10+
# * filename defaults to .lima.yaml when only a path is specified
11+
# * .yaml default extension
12+
# * .lima.yaml files may be treated as symlinks
13+
# * default branch lookup when not specified
14+
#
15+
# The repo files are:
16+
#
17+
# ├── .lima.yaml -> templates/demo.yaml
18+
# ├── docs
19+
# │ └── .lima.yaml -> ../templates/demo.yaml
20+
# └── templates
21+
# └── demo.yaml
22+
#
23+
# Both the `main` branch and the `v0.0.0` tag have this layout.
24+
25+
# All these URLs should redirect to the same template URL (either on "main" or at "v0.0.0"):
26+
# "https://raw.githubusercontent.com/jandubois/jandubois/${tag}/templates/demo.yaml"
27+
URLS=(
28+
github:jandubois/jandubois/templates/demo.yaml@main
29+
github:jandubois/jandubois/templates/demo.yaml
30+
github:jandubois/jandubois/templates/demo
31+
github:jandubois/jandubois/.lima.yaml
32+
github:jandubois/jandubois/@v0.0.0
33+
github:jandubois/jandubois
34+
github:jandubois//templates/demo.yaml@main
35+
github:jandubois//templates/demo.yaml
36+
github:jandubois//templates/demo
37+
github:jandubois//.lima.yaml
38+
github:jandubois//@v0.0.0
39+
github:jandubois//
40+
github:jandubois/
41+
42+
github:jandubois
43+
github:jandubois/jandubois/docs/.lima.yaml@main
44+
github:jandubois/jandubois/docs/.lima.yaml
45+
github:jandubois/jandubois/docs/.lima
46+
github:jandubois/jandubois/docs/
47+
github:jandubois//docs/[email protected]
48+
github:jandubois//docs/.lima.yaml
49+
github:jandubois//docs/.lima
50+
github:jandubois//docs/@v0.0.0
51+
github:jandubois//docs/
52+
)
53+
54+
url() {
55+
run_e "$1" limactl template url "$2"
56+
}
57+
58+
test_jandubois_url() {
59+
local url=$1
60+
local tag="main"
61+
if [[ $url == *v0.0.0* ]]; then
62+
tag="v0.0.0"
63+
fi
64+
65+
url -0 "$url"
66+
assert_output "https://raw.githubusercontent.com/jandubois/jandubois/${tag}/templates/demo.yaml"
67+
}
68+
69+
# Dynamically register a @test for each URL in the list
70+
for url in "${URLS[@]}"; do
71+
bats_test_function --description "$url" -- test_jandubois_url "$url"
72+
done
73+
74+
@test '.lima.yaml is retained when it is not a symlink' {
75+
url -0 'github:jandubois//test/'
76+
assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.lima.yaml'
77+
}
78+
79+
@test 'hidden files without an extension get a .yaml extension' {
80+
url -0 'github:jandubois//test/.hidden'
81+
assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.hidden.yaml'
82+
}
83+
84+
@test 'files that have an extension do not get a .yaml extension' {
85+
url -0 'github:jandubois//test/.script.sh'
86+
assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.script.sh'
87+
}
88+
89+
@test 'github: URLs are EXPERIMENTAL' {
90+
url -0 'github:jandubois'
91+
assert_stderr --regexp 'warning.+GitHub locator .* replaced with .* EXPERIMENTAL'
92+
}
93+
94+
@test 'Empty github: url returns an error' {
95+
url -1 'github:'
96+
assert_fatal 'github: URL must contain at least an ORG, got ""'
97+
}
98+
99+
@test 'Missing org returns an error' {
100+
url -1 'github:/jandubois'
101+
assert_fatal 'github: URL must contain at least an ORG, got ""'
102+
}

pkg/limatmpl/github.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package limatmpl
5+
6+
import (
7+
"cmp"
8+
"context"
9+
"encoding/json"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"os"
15+
"path"
16+
"strings"
17+
)
18+
19+
// transformGitHubURL transforms a github: URL to a raw.githubusercontent.com URL.
20+
// Input format: ORG/REPO[/PATH][@BRANCH]
21+
//
22+
// If REPO is missing, it will be set the same as ORG.
23+
// If BRANCH is missing, it will be queried from GitHub.
24+
// If PATH filename has no extension, it will get .yaml.
25+
// If PATH is just a directory (trailing slash), it will be set to .lima.yaml
26+
// IF FILE is .lima.yaml and contents looks like a symlink, it will be replaced by the symlink target.
27+
func transformGitHubURL(ctx context.Context, input string) (string, error) {
28+
// Check for explicit branch specification with @ at the end
29+
var branch string
30+
if idx := strings.LastIndex(input, "@"); idx != -1 {
31+
branch = input[idx+1:]
32+
input = input[:idx]
33+
}
34+
35+
parts := strings.Split(input, "/")
36+
for len(parts) < 2 {
37+
parts = append(parts, "")
38+
}
39+
40+
org := parts[0]
41+
if org == "" {
42+
return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input)
43+
}
44+
45+
// If REPO is omitted (github:ORG or github:ORG//PATH), default it to ORG
46+
repo := cmp.Or(parts[1], org)
47+
pathPart := strings.Join(parts[2:], "/")
48+
49+
if pathPart == "" {
50+
pathPart = ".lima.yaml"
51+
} else {
52+
// If path ends with /, it's a directory, so append .lima
53+
if strings.HasSuffix(pathPart, "/") {
54+
pathPart += ".lima"
55+
}
56+
57+
// If the filename (excluding first char for hidden files) has no extension, add .yaml
58+
filename := path.Base(pathPart)
59+
if !strings.Contains(filename[1:], ".") {
60+
pathPart += ".yaml"
61+
}
62+
}
63+
64+
// Query default branch if no branch was specified
65+
if branch == "" {
66+
var err error
67+
branch, err = getGitHubDefaultBranch(ctx, org, repo)
68+
if err != nil {
69+
return "", fmt.Errorf("failed to get default branch for %s/%s: %w", org, repo, err)
70+
}
71+
}
72+
73+
// If filename is .lima.yaml, check if it's a symlink/redirect to another file
74+
if path.Base(pathPart) == ".lima.yaml" {
75+
if redirectPath, err := resolveGitHubSymlink(ctx, org, repo, branch, pathPart); err == nil {
76+
pathPart = redirectPath
77+
}
78+
}
79+
80+
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, pathPart), nil
81+
}
82+
83+
// getGitHubDefaultBranch queries the GitHub API to get the default branch for a repository.
84+
func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, error) {
85+
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", org, repo)
86+
87+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody)
88+
if err != nil {
89+
return "", fmt.Errorf("failed to create request: %w", err)
90+
}
91+
92+
req.Header.Set("User-Agent", "lima")
93+
req.Header.Set("Accept", "application/vnd.github.v3+json")
94+
95+
// Check for GitHub token in environment for authenticated requests (higher rate limit)
96+
token := cmp.Or(os.Getenv("GH_TOKEN"), os.Getenv("GITHUB_TOKEN"))
97+
if token != "" {
98+
req.Header.Set("Authorization", "token "+token)
99+
}
100+
101+
resp, err := http.DefaultClient.Do(req)
102+
if err != nil {
103+
return "", fmt.Errorf("failed to query GitHub API: %w", err)
104+
}
105+
defer resp.Body.Close()
106+
107+
body, err := io.ReadAll(resp.Body)
108+
if err != nil {
109+
return "", fmt.Errorf("failed to read GitHub API response: %w", err)
110+
}
111+
112+
if resp.StatusCode != http.StatusOK {
113+
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
114+
}
115+
116+
var repoData struct {
117+
DefaultBranch string `json:"default_branch"`
118+
}
119+
120+
if err := json.Unmarshal(body, &repoData); err != nil {
121+
return "", fmt.Errorf("failed to parse GitHub API response: %w", err)
122+
}
123+
124+
if repoData.DefaultBranch == "" {
125+
return "", fmt.Errorf("repository %s/%s has no default branch", org, repo)
126+
}
127+
128+
return repoData.DefaultBranch, nil
129+
}
130+
131+
// resolveGitHubSymlink checks if a file at the given path is a symlink/redirect to another file.
132+
// If the file contains a single line without YAML content, it's treated as a path to the actual file.
133+
// Returns the redirect path if found, or the original path otherwise.
134+
func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath string) (string, error) {
135+
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
136+
137+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
138+
if err != nil {
139+
return "", fmt.Errorf("failed to create request: %w", err)
140+
}
141+
142+
req.Header.Set("User-Agent", "lima")
143+
144+
resp, err := http.DefaultClient.Do(req)
145+
if err != nil {
146+
return "", fmt.Errorf("failed to fetch file: %w", err)
147+
}
148+
defer resp.Body.Close()
149+
150+
if resp.StatusCode != http.StatusOK {
151+
return "", fmt.Errorf("file not found or inaccessible: status %d", resp.StatusCode)
152+
}
153+
154+
// Read first 1KB to check the file content
155+
buf := make([]byte, 1024)
156+
n, err := resp.Body.Read(buf)
157+
if err != nil && !errors.Is(err, io.EOF) {
158+
return "", fmt.Errorf("failed to read file content: %w", err)
159+
}
160+
content := string(buf[:n])
161+
162+
// A symlink must be a single line (without trailing newline), no spaces, no colons
163+
if !(content == "" || strings.ContainsAny(content, "\n :")) {
164+
// symlink is relative to the directory of filePath
165+
return path.Join(path.Dir(filePath), content), nil
166+
}
167+
return filePath, nil
168+
}

pkg/limatmpl/locator.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,15 @@ func TransformCustomURL(ctx context.Context, locator string) (string, error) {
312312
return newLocator, nil
313313
}
314314

315+
if u.Scheme == "github" {
316+
newLocator, err := transformGitHubURL(ctx, u.Opaque)
317+
if err != nil {
318+
return "", err
319+
}
320+
logrus.Warnf("GitHub locator %q replaced with %q is still EXPERIMENTAL", locator, newLocator)
321+
return newLocator, nil
322+
}
323+
315324
plugin, err := plugins.Find("url-" + u.Scheme)
316325
if err != nil {
317326
return "", err

0 commit comments

Comments
 (0)