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  }