Files
Packer-Cn/packer/plugin-getter/github/getter.go
T
2025-07-19 13:48:15 +05:30

387 lines
10 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package github
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/google/go-github/v33/github"
"github.com/hashicorp/packer/hcl2template/addrs"
plugingetter "github.com/hashicorp/packer/packer/plugin-getter"
"golang.org/x/oauth2"
)
const (
ghTokenAccessor = "PACKER_GITHUB_API_TOKEN"
defaultUserAgent = "packer-github-plugin-getter"
defaultHostname = "github.com"
)
type Getter struct {
Client *github.Client
HttpClient *http.Client
UserAgent string
}
var _ plugingetter.Getter = &Getter{}
type PluginMetadata struct {
Versions map[string]PluginVersion `json:"versions"`
}
type PluginVersion struct {
Name string `json:"name"`
Version string `json:"version"`
}
func transformChecksumStream() func(in io.ReadCloser) (io.ReadCloser, error) {
return func(in io.ReadCloser) (io.ReadCloser, error) {
defer in.Close()
rd := bufio.NewReader(in)
buffer := bytes.NewBufferString("[")
json := json.NewEncoder(buffer)
for i := 0; ; i++ {
line, err := rd.ReadString('\n')
if err != nil {
if err != io.EOF {
return nil, fmt.Errorf(
"Error reading checksum file: %s", err)
}
break
}
parts := strings.Fields(line)
switch len(parts) {
case 2: // nominal case
checksumString, checksumFilename := parts[0], parts[1]
if i > 0 {
_, _ = buffer.WriteString(",")
}
if err := json.Encode(struct {
Checksum string `json:"checksum"`
Filename string `json:"filename"`
}{
Checksum: checksumString,
Filename: checksumFilename,
}); err != nil {
return nil, err
}
}
}
_, _ = buffer.WriteString("]")
return io.NopCloser(buffer), nil
}
}
// transformVersionStream get a stream from github tags and transforms it into
// something Packer wants, namely a json list of Release.
func transformVersionStream(in io.ReadCloser) (io.ReadCloser, error) {
if in == nil {
return nil, fmt.Errorf("transformVersionStream got nil body")
}
defer in.Close()
dec := json.NewDecoder(in)
m := []struct {
Ref string `json:"ref"`
}{}
if err := dec.Decode(&m); err != nil {
return nil, err
}
out := []plugingetter.Release{}
for _, m := range m {
out = append(out, plugingetter.Release{
Version: strings.TrimPrefix(m.Ref, "refs/tags/"),
})
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(out); err != nil {
return nil, err
}
return io.NopCloser(buf), nil
}
// transformReleasesVersionStream get a stream from github tags and transforms it into
// something Packer wants, namely a json list of Release.
func transformReleasesVersionStream(in io.ReadCloser) (io.ReadCloser, error) {
if in == nil {
return nil, fmt.Errorf("transformReleasesVersionStream got nil body")
}
defer in.Close()
dec := json.NewDecoder(in)
var m PluginMetadata
if err := dec.Decode(&m); err != nil {
return nil, err
}
var out []plugingetter.Release
for _, m := range m.Versions {
out = append(out, plugingetter.Release{
Version: "v" + m.Version,
})
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(out); err != nil {
return nil, err
}
return io.NopCloser(buf), nil
}
// HostSpecificTokenAuthTransport makes sure the http roundtripper only sets an
// auth token for requests aimed at a specific host.
//
// This helps for example to get release files from Github as Github will
// redirect to s3 which will error if we give it a Github auth token.
type HostSpecificTokenAuthTransport struct {
// Host to TokenSource map
TokenSources map[string]oauth2.TokenSource
// actual RoundTripper, nil means we use the default one from http.
Base http.RoundTripper
}
// RoundTrip authorizes and authenticates the request with an
// access token from Transport's Source.
func (t *HostSpecificTokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
source, found := t.TokenSources[req.Host]
if found {
reqBodyClosed := false
if req.Body != nil {
defer func() {
if !reqBodyClosed {
req.Body.Close()
}
}()
}
if source == nil {
return nil, errors.New("transport's Source is nil")
}
token, err := source.Token()
if err != nil {
return nil, err
}
token.SetAuthHeader(req)
// req.Body is assumed to be closed by the base RoundTripper.
reqBodyClosed = true
}
return t.base().RoundTrip(req)
}
func (t *HostSpecificTokenAuthTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
type GithubPlugin struct {
Hostname string
Namespace string
Type string
}
func NewGithubPlugin(source *addrs.Plugin) (*GithubPlugin, error) {
parts := source.Parts()
if len(parts) != 3 {
return nil, fmt.Errorf("Invalid github.com URI %q: a Github-compatible source must be in the github.com/<namespace>/<name> format.", source.String())
}
if parts[0] != defaultHostname {
return nil, fmt.Errorf("%q doesn't appear to be a valid %q source address; check source and try again.", source.String(), defaultHostname)
}
return &GithubPlugin{
Hostname: parts[0],
Namespace: parts[1],
Type: strings.Replace(parts[2], "packer-plugin-", "", 1),
}, nil
}
func (gp GithubPlugin) RealRelativePath() string {
return path.Join(
gp.Namespace,
fmt.Sprintf("packer-plugin-%s", gp.Type),
)
}
func (gp GithubPlugin) PluginType() string {
return fmt.Sprintf("packer-plugin-%s", gp.Type)
}
func (g *Getter) Get(what string, opts plugingetter.GetOptions) (io.ReadCloser, error) {
ghURI, err := NewGithubPlugin(opts.PluginRequirement.Identifier)
if err != nil {
return nil, err
}
ctx := context.TODO()
if g.Client == nil {
var tc *http.Client
if tk := os.Getenv(ghTokenAccessor); tk != "" {
log.Printf("[DEBUG] github-getter: using %s", ghTokenAccessor)
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: tk},
)
tc = &http.Client{
Transport: &HostSpecificTokenAuthTransport{
TokenSources: map[string]oauth2.TokenSource{
"api.github.com": ts,
},
},
}
} else {
log.Printf("[WARNING] github-getter: no GitHub token set, if you intend to install plugins often, please set the %s env var", ghTokenAccessor)
}
g.Client = github.NewClient(tc)
g.Client.UserAgent = defaultUserAgent
if g.UserAgent != "" {
g.Client.UserAgent = g.UserAgent
}
}
var req *http.Request
transform := func(in io.ReadCloser) (io.ReadCloser, error) {
return in, nil
}
switch what {
case "releases":
u := filepath.ToSlash("/repos/" + ghURI.RealRelativePath() + "/git/matching-refs/tags")
req, err = g.Client.NewRequest("GET", u, nil)
transform = transformVersionStream
case "sha256":
// something like https://github.com/sylviamoss/packer-plugin-comment/releases/download/v0.2.11/packer-plugin-comment_v0.2.11_x5_SHA256SUMS
u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.PluginRequirement.FilenamePrefix() + opts.Version() + "_SHA256SUMS")
req, err = g.Client.NewRequest(
"GET",
u,
nil,
)
transform = transformChecksumStream()
case "zip":
u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.ExpectedZipFilename())
req, err = g.Client.NewRequest(
"GET",
u,
nil,
)
default:
return nil, fmt.Errorf("%q not implemented", what)
}
if err != nil {
return nil, err
}
log.Printf("[DEBUG] github-getter: getting %q", req.URL)
resp, err := g.Client.BareDo(ctx, req)
if err != nil {
// here BareDo will return an err if the request failed or if the status
// is not considered a valid http status. So we have to close the body
// if it's not nil.
if resp != nil {
resp.Body.Close()
}
switch err := err.(type) {
case *github.RateLimitError:
return nil, &plugingetter.RateLimitError{
SetableEnvVar: ghTokenAccessor,
Err: err,
ResetTime: err.Rate.Reset.Time,
}
default:
log.Printf("[TRACE] failed requesting: %T. %v", err, err)
return nil, err
}
}
return transform(resp.Body)
}
func (g *Getter) GetOfficialRelease(what string, opts plugingetter.GetOptions) (io.ReadCloser, error) {
ghURI, err := NewGithubPlugin(opts.PluginRequirement.Identifier)
if err != nil {
return nil, err
}
if g.HttpClient == nil {
g.HttpClient = &http.Client{}
}
var req *http.Request
transform := transformZipStream()
switch what {
case "releases":
url := filepath.ToSlash("https://releases.hashicorp.com/" + ghURI.PluginType() + "/index.json")
req, err = http.NewRequest("GET", url, nil)
transform = transformReleasesVersionStream
case "sha256":
// https://github.com/sylviamoss/packer-plugin-comment/releases/download/v0.2.11/packer-plugin-comment_v0.2.11_x5_SHA256SUMS
url := filepath.ToSlash("https://releases.hashicorp.com/" + ghURI.PluginType() + "/" + opts.VersionString() + "/" + ghURI.PluginType() + "_" + opts.VersionString() + "_SHA256SUMS")
transform = transformChecksumStream()
req, err = http.NewRequest("GET", url, nil)
case "zip":
// https://releases.hashicorp.com/terraform-provider-akamai/8.0.0/terraform-provider-akamai_8.0.0_darwin_arm64.zip
url := filepath.ToSlash("https://releases.hashicorp.com/" + ghURI.PluginType() + "/" + opts.VersionString() + "/" + opts.ExpectedZipFilename())
req, err = http.NewRequest("GET", url, nil)
default:
return nil, fmt.Errorf("%q not implemented", what)
}
if err != nil {
log.Printf("[ERROR] http-getter: error creating request for %q: %s", what, err)
return nil, err
}
resp, err := g.HttpClient.Do(req)
if err != nil || resp.StatusCode >= 400 {
log.Printf("[ERROR] Got error while getting data from releases.hashicorp.com, %v", err)
return nil, plugingetter.HTTPFailure
}
defer func(Body io.ReadCloser) {
err = Body.Close()
if err != nil {
log.Printf("[ERROR] http-getter: error closing response body: %s", err)
}
}(resp.Body)
return transform(resp.Body)
}
func transformZipStream() func(in io.ReadCloser) (io.ReadCloser, error) {
return func(in io.ReadCloser) (io.ReadCloser, error) {
defer in.Close()
buf := new(bytes.Buffer)
_, err := io.Copy(buf, in)
if err != nil {
panic(err)
}
return io.NopCloser(buf), nil
}
}