Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
lima-vm
GitHub Repository: lima-vm/lima
Path: blob/master/pkg/limatmpl/github.go
2606 views
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
const defaultFilename = ".lima.yaml"
20
21
// transformGitHubURL transforms a github: URL to a raw.githubusercontent.com URL.
22
// Input format: ORG/REPO[/PATH][@BRANCH]
23
//
24
// If REPO is missing, it will be set the same as ORG.
25
// If BRANCH is missing, it will be queried from GitHub.
26
// If PATH filename has no extension, it will get .yaml.
27
// If PATH is just a directory (trailing slash), it will be set to .lima.yaml
28
// IF FILE is .lima.yaml and contents looks like a symlink, it will be replaced by the symlink target.
29
func transformGitHubURL(ctx context.Context, input string) (string, error) {
30
input, origBranch, _ := strings.Cut(input, "@")
31
32
parts := strings.Split(input, "/")
33
for len(parts) < 2 {
34
parts = append(parts, "")
35
}
36
org := parts[0]
37
if org == "" {
38
return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input)
39
}
40
// If REPO is omitted (github:ORG or github:ORG//PATH), default it to ORG
41
repo := cmp.Or(parts[1], org)
42
filePath := strings.Join(parts[2:], "/")
43
44
if filePath == "" {
45
filePath = defaultFilename
46
} else {
47
// If path ends with / then it's a directory, so append .lima
48
if strings.HasSuffix(filePath, "/") {
49
filePath += defaultFilename
50
}
51
52
// If the filename (excluding first char for hidden files) has no extension, add .yaml
53
filename := path.Base(filePath)
54
if !strings.Contains(filename[1:], ".") {
55
filePath += ".yaml"
56
}
57
}
58
59
// Query default branch if no branch was specified
60
branch := origBranch
61
if branch == "" {
62
var err error
63
branch, err = getGitHubDefaultBranch(ctx, org, repo)
64
if err != nil {
65
return "", fmt.Errorf("failed to get default branch for %s/%s: %w", org, repo, err)
66
}
67
}
68
69
// If filename has a .yaml/.yml extension, check if it's a symlink/redirect to another file
70
ext := strings.ToLower(path.Ext(filePath))
71
if ext == ".yaml" || ext == ".yml" {
72
return resolveGitHubSymlink(ctx, org, repo, branch, filePath, origBranch)
73
}
74
return githubUserContentURL(org, repo, branch, filePath), nil
75
}
76
77
func githubUserContentURL(org, repo, branch, filePath string) string {
78
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
79
}
80
81
func getGitHubUserContent(ctx context.Context, org, repo, branch, filePath string) (*http.Response, error) {
82
url := githubUserContentURL(org, repo, branch, filePath)
83
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
84
if err != nil {
85
return nil, fmt.Errorf("failed to create request: %w", err)
86
}
87
req.Header.Set("User-Agent", "lima")
88
return http.DefaultClient.Do(req)
89
}
90
91
// getGitHubDefaultBranch queries the GitHub API to get the default branch for a repository.
92
func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, error) {
93
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", org, repo)
94
95
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody)
96
if err != nil {
97
return "", fmt.Errorf("failed to create request: %w", err)
98
}
99
100
req.Header.Set("User-Agent", "lima")
101
req.Header.Set("Accept", "application/vnd.github.v3+json")
102
103
// Check for GitHub token in environment for authenticated requests (higher rate limit)
104
token := cmp.Or(os.Getenv("GH_TOKEN"), os.Getenv("GITHUB_TOKEN"))
105
if token != "" {
106
req.Header.Set("Authorization", "token "+token)
107
}
108
109
resp, err := http.DefaultClient.Do(req)
110
if err != nil {
111
return "", fmt.Errorf("failed to query GitHub API: %w", err)
112
}
113
defer resp.Body.Close()
114
115
body, err := io.ReadAll(resp.Body)
116
if err != nil {
117
return "", fmt.Errorf("failed to read GitHub API response: %w", err)
118
}
119
if resp.StatusCode != http.StatusOK {
120
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
121
}
122
123
var repoData struct {
124
DefaultBranch string `json:"default_branch"`
125
}
126
if err := json.Unmarshal(body, &repoData); err != nil {
127
return "", fmt.Errorf("failed to parse GitHub API response: %w", err)
128
}
129
if repoData.DefaultBranch == "" {
130
return "", fmt.Errorf("repository %s/%s has no default branch", org, repo)
131
}
132
return repoData.DefaultBranch, nil
133
}
134
135
// resolveGitHubSymlink checks if a file at the given path is a symlink/redirect to another file.
136
// If the file contains a single line without newline, space, or colon then it's treated as a path to the actual file.
137
// Returns a URL to the redirect path if found, or a URL for original path otherwise.
138
func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath, origBranch string) (string, error) {
139
resp, err := getGitHubUserContent(ctx, org, repo, branch, filePath)
140
if err != nil {
141
return "", fmt.Errorf("failed to fetch file: %w", err)
142
}
143
defer resp.Body.Close()
144
145
// Special rule for branch/tag propagation for github:ORG// requests.
146
if resp.StatusCode == http.StatusNotFound && repo == org {
147
defaultBranch, err := getGitHubDefaultBranch(ctx, org, repo)
148
if err == nil {
149
return resolveGitHubRedirect(ctx, org, repo, defaultBranch, filePath, branch)
150
}
151
}
152
if resp.StatusCode != http.StatusOK {
153
return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
154
}
155
156
// Read first 1KB to check the file content
157
buf := make([]byte, 1024)
158
n, err := resp.Body.Read(buf)
159
if err != nil && !errors.Is(err, io.EOF) {
160
return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
161
}
162
content := string(buf[:n])
163
164
// Symlink can also be a github: redirect if we are in a github:ORG// repo.
165
if repo == org && strings.HasPrefix(content, "github:") {
166
return validateGitHubRedirect(content, org, origBranch, resp.Request.URL.String())
167
}
168
169
// A symlink must be a single line (without trailing newline), no spaces, no colons
170
if !(content == "" || strings.ContainsAny(content, "\n :")) {
171
// symlink is relative to the directory of filePath
172
filePath = path.Join(path.Dir(filePath), content)
173
}
174
return githubUserContentURL(org, repo, branch, filePath), nil
175
}
176
177
// resolveGitHubRedirect checks if a file at the given path is a github: URL to another file within the same repo.
178
// Returns the URL, or an error if the file doesn't exist, or doesn't start with github:ORG.
179
func resolveGitHubRedirect(ctx context.Context, org, repo, defaultBranch, filePath, origBranch string) (string, error) {
180
// Refetch the filepath from the defaultBranch
181
resp, err := getGitHubUserContent(ctx, org, repo, defaultBranch, filePath)
182
if err != nil {
183
return "", fmt.Errorf("failed to fetch file: %w", err)
184
}
185
defer resp.Body.Close()
186
if resp.StatusCode != http.StatusOK {
187
return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
188
}
189
body, err := io.ReadAll(resp.Body)
190
if err != nil {
191
return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
192
}
193
return validateGitHubRedirect(string(body), org, origBranch, resp.Request.URL.String())
194
}
195
196
func validateGitHubRedirect(body, org, origBranch, url string) (string, error) {
197
redirect, _, _ := strings.Cut(body, "\n")
198
redirect = strings.TrimSpace(redirect)
199
200
if !strings.HasPrefix(redirect, "github:"+org+"/") {
201
return "", fmt.Errorf(`redirect %q is not a "github:%s" URL (from %q)`, redirect, org, url)
202
}
203
if strings.ContainsRune(redirect, '@') {
204
return "", fmt.Errorf("redirect %q must not include a branch/tag/sha (from %q)", redirect, url)
205
}
206
// If the origBranch is empty, then we need to look up the default branch in the redirect
207
if origBranch != "" {
208
redirect += "@" + origBranch
209
}
210
return redirect, nil
211
}
212
213