github.com/hashicorp/packer@v1.14.3/packer/plugin-getter/github/getter.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: BUSL-1.1 3 4 package github 5 6 import ( 7 "bufio" 8 "bytes" 9 "context" 10 "encoding/json" 11 "errors" 12 "fmt" 13 "io" 14 "log" 15 "net/http" 16 "os" 17 "path" 18 "path/filepath" 19 "strings" 20 21 plugingetter "github.com/hashicorp/packer/packer/plugin-getter" 22 23 "github.com/google/go-github/v33/github" 24 "github.com/hashicorp/packer/hcl2template/addrs" 25 "golang.org/x/oauth2" 26 ) 27 28 const ( 29 ghTokenAccessor = "PACKER_GITHUB_API_TOKEN" 30 defaultUserAgent = "packer-github-plugin-getter" 31 defaultHostname = "github.com" 32 ) 33 34 type Getter struct { 35 Client *github.Client 36 UserAgent string 37 Name string 38 } 39 40 var _ plugingetter.Getter = &Getter{} 41 42 type PluginMetadata struct { 43 Versions map[string]PluginVersion `json:"versions"` 44 } 45 46 type PluginVersion struct { 47 Name string `json:"name"` 48 Version string `json:"version"` 49 } 50 51 func TransformChecksumStream() func(in io.ReadCloser) (io.ReadCloser, error) { 52 return func(in io.ReadCloser) (io.ReadCloser, error) { 53 defer in.Close() 54 rd := bufio.NewReader(in) 55 buffer := bytes.NewBufferString("[") 56 json := json.NewEncoder(buffer) 57 for i := 0; ; i++ { 58 line, err := rd.ReadString('\n') 59 if err != nil { 60 if err != io.EOF { 61 return nil, fmt.Errorf( 62 "Error reading checksum file: %s", err) 63 } 64 break 65 } 66 parts := strings.Fields(line) 67 switch len(parts) { 68 case 2: // nominal case 69 checksumString, checksumFilename := parts[0], parts[1] 70 71 if i > 0 { 72 _, _ = buffer.WriteString(",") 73 } 74 if err := json.Encode(struct { 75 Checksum string `json:"checksum"` 76 Filename string `json:"filename"` 77 }{ 78 Checksum: checksumString, 79 Filename: checksumFilename, 80 }); err != nil { 81 return nil, err 82 } 83 } 84 } 85 _, _ = buffer.WriteString("]") 86 return io.NopCloser(buffer), nil 87 } 88 } 89 90 // transformVersionStream get a stream from github tags and transforms it into 91 // something Packer wants, namely a json list of Release. 92 func transformVersionStream(in io.ReadCloser) (io.ReadCloser, error) { 93 if in == nil { 94 return nil, fmt.Errorf("transformVersionStream got nil body") 95 } 96 defer in.Close() 97 dec := json.NewDecoder(in) 98 99 m := []struct { 100 Ref string `json:"ref"` 101 }{} 102 if err := dec.Decode(&m); err != nil { 103 return nil, err 104 } 105 106 out := []plugingetter.Release{} 107 for _, m := range m { 108 out = append(out, plugingetter.Release{ 109 Version: strings.TrimPrefix(m.Ref, "refs/tags/"), 110 }) 111 } 112 113 buf := &bytes.Buffer{} 114 if err := json.NewEncoder(buf).Encode(out); err != nil { 115 return nil, err 116 } 117 118 return io.NopCloser(buf), nil 119 } 120 121 // HostSpecificTokenAuthTransport makes sure the http roundtripper only sets an 122 // auth token for requests aimed at a specific host. 123 // 124 // This helps for example to get release files from Github as Github will 125 // redirect to s3 which will error if we give it a Github auth token. 126 type HostSpecificTokenAuthTransport struct { 127 // Host to TokenSource map 128 TokenSources map[string]oauth2.TokenSource 129 130 // actual RoundTripper, nil means we use the default one from http. 131 Base http.RoundTripper 132 } 133 134 // RoundTrip authorizes and authenticates the request with an 135 // access token from Transport's Source. 136 func (t *HostSpecificTokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { 137 source, found := t.TokenSources[req.Host] 138 if found { 139 reqBodyClosed := false 140 if req.Body != nil { 141 defer func() { 142 if !reqBodyClosed { 143 req.Body.Close() 144 } 145 }() 146 } 147 148 if source == nil { 149 return nil, errors.New("transport's Source is nil") 150 } 151 token, err := source.Token() 152 if err != nil { 153 return nil, err 154 } 155 156 token.SetAuthHeader(req) 157 158 // req.Body is assumed to be closed by the base RoundTripper. 159 reqBodyClosed = true 160 } 161 162 return t.base().RoundTrip(req) 163 } 164 165 func (t *HostSpecificTokenAuthTransport) base() http.RoundTripper { 166 if t.Base != nil { 167 return t.Base 168 } 169 return http.DefaultTransport 170 } 171 172 type GithubPlugin struct { 173 Hostname string 174 Namespace string 175 Type string 176 } 177 178 func NewGithubPlugin(source *addrs.Plugin) (*GithubPlugin, error) { 179 parts := source.Parts() 180 if len(parts) != 3 { 181 return nil, fmt.Errorf("Invalid github.com URI %q: a Github-compatible source must be in the github.com/<namespace>/<name> format.", source.String()) 182 } 183 184 if parts[0] != defaultHostname { 185 return nil, fmt.Errorf("%q doesn't appear to be a valid %q source address; check source and try again.", source.String(), defaultHostname) 186 } 187 188 return &GithubPlugin{ 189 Hostname: parts[0], 190 Namespace: parts[1], 191 Type: strings.Replace(parts[2], "packer-plugin-", "", 1), 192 }, nil 193 } 194 195 func (gp GithubPlugin) RealRelativePath() string { 196 return path.Join( 197 gp.Namespace, 198 fmt.Sprintf("packer-plugin-%s", gp.Type), 199 ) 200 } 201 202 func (gp GithubPlugin) PluginType() string { 203 return fmt.Sprintf("packer-plugin-%s", gp.Type) 204 } 205 206 func (g *Getter) Get(what string, opts plugingetter.GetOptions) (io.ReadCloser, error) { 207 log.Printf("[TRACE] Getting %s of %s plugin from %s", what, opts.PluginRequirement.Identifier, g.Name) 208 ghURI, err := NewGithubPlugin(opts.PluginRequirement.Identifier) 209 if err != nil { 210 return nil, err 211 } 212 213 ctx := context.TODO() 214 if g.Client == nil { 215 var tc *http.Client 216 if tk := os.Getenv(ghTokenAccessor); tk != "" { 217 log.Printf("[DEBUG] github-getter: using %s", ghTokenAccessor) 218 ts := oauth2.StaticTokenSource( 219 &oauth2.Token{AccessToken: tk}, 220 ) 221 tc = &http.Client{ 222 Transport: &HostSpecificTokenAuthTransport{ 223 TokenSources: map[string]oauth2.TokenSource{ 224 "api.github.com": ts, 225 }, 226 }, 227 } 228 } else { 229 log.Printf("[WARNING] github-getter: no GitHub token set, if you intend to install plugins often, please set the %s env var", ghTokenAccessor) 230 } 231 g.Client = github.NewClient(tc) 232 g.Client.UserAgent = defaultUserAgent 233 if g.UserAgent != "" { 234 g.Client.UserAgent = g.UserAgent 235 } 236 } 237 238 var req *http.Request 239 transform := func(in io.ReadCloser) (io.ReadCloser, error) { 240 return in, nil 241 } 242 243 switch what { 244 case "releases": 245 u := filepath.ToSlash("/repos/" + ghURI.RealRelativePath() + "/git/matching-refs/tags") 246 req, err = g.Client.NewRequest("GET", u, nil) 247 transform = transformVersionStream 248 case "sha256": 249 // something like https://github.com/sylviamoss/packer-plugin-comment/releases/download/v0.2.11/packer-plugin-comment_v0.2.11_x5_SHA256SUMS 250 u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.PluginRequirement.FilenamePrefix() + opts.Version() + "_SHA256SUMS") 251 req, err = g.Client.NewRequest( 252 "GET", 253 u, 254 nil, 255 ) 256 transform = TransformChecksumStream() 257 case "zip": 258 u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.ExpectedZipFilename()) 259 req, err = g.Client.NewRequest( 260 "GET", 261 u, 262 nil, 263 ) 264 265 default: 266 return nil, fmt.Errorf("%q not implemented", what) 267 } 268 if err != nil { 269 return nil, err 270 } 271 log.Printf("[DEBUG] github-getter: getting %q", req.URL) 272 resp, err := g.Client.BareDo(ctx, req) 273 if err != nil { 274 // here BareDo will return an err if the request failed or if the status 275 // is not considered a valid http status. So we have to close the body 276 // if it's not nil. 277 if resp != nil { 278 resp.Body.Close() 279 } 280 switch err := err.(type) { 281 case *github.RateLimitError: 282 return nil, &plugingetter.RateLimitError{ 283 SetableEnvVar: ghTokenAccessor, 284 Err: err, 285 ResetTime: err.Rate.Reset.Time, 286 } 287 default: 288 log.Printf("[TRACE] failed requesting: %T. %v", err, err) 289 return nil, err 290 } 291 292 } 293 294 return transform(resp.Body) 295 } 296 297 // Init method: a file inside will look like so: 298 // 299 // packer-plugin-comment_v0.2.12_x5.0_freebsd_amd64.zip 300 func (g *Getter) Init(req *plugingetter.Requirement, entry *plugingetter.ChecksumFileEntry) error { 301 filename := entry.Filename 302 res := strings.TrimPrefix(filename, req.FilenamePrefix()) 303 // res now looks like v0.2.12_x5.0_freebsd_amd64.zip 304 305 entry.Ext = filepath.Ext(res) 306 307 res = strings.TrimSuffix(res, entry.Ext) 308 // res now looks like v0.2.12_x5.0_freebsd_amd64 309 310 parts := strings.Split(res, "_") 311 // ["v0.2.12", "x5.0", "freebsd", "amd64"] 312 if len(parts) < 4 { 313 return fmt.Errorf("malformed filename expected %s{version}_x{protocol-version}_{os}_{arch}", req.FilenamePrefix()) 314 } 315 316 entry.BinVersion, entry.ProtVersion, entry.Os, entry.Arch = parts[0], parts[1], parts[2], parts[3] 317 318 return nil 319 } 320 321 func (g *Getter) Validate(opt plugingetter.GetOptions, expectedVersion string, installOpts plugingetter.BinaryInstallationOptions, entry *plugingetter.ChecksumFileEntry) error { 322 expectedBinVersion := "v" + expectedVersion 323 if entry.BinVersion != expectedBinVersion { 324 return fmt.Errorf("wrong version: %s does not match expected %s", entry.BinVersion, expectedBinVersion) 325 } 326 if entry.Os != installOpts.OS || entry.Arch != installOpts.ARCH { 327 return fmt.Errorf("wrong system, expected %s_%s", installOpts.OS, installOpts.ARCH) 328 } 329 330 return installOpts.CheckProtocolVersion(entry.ProtVersion) 331 } 332 333 func (g *Getter) ExpectedFileName(pr *plugingetter.Requirement, version string, entry *plugingetter.ChecksumFileEntry, zipFileName string) string { 334 return zipFileName 335 }