-
Notifications
You must be signed in to change notification settings - Fork 706
Implement custom github:
URL scheme in Go
#4134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
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 ""' | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to add unit tests for functions in the file? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
jandubois marked this conversation as resolved.
Show resolved
Hide resolved
|
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 | ||
} |
jandubois marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, sogithub:
fits right in, and maybegh:
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.There was a problem hiding this comment.
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.