mirror of
https://github.com/grafana/grafana.git
synced 2024-12-01 21:19:28 -06:00
ec82719372
* refactor * implement with infra log for now * undo moving * update package name * update name * fix tests * update pretty signature * update naming * simplify * fix typo * delete comment * fix import * retrigger
250 lines
6.5 KiB
Go
250 lines
6.5 KiB
Go
package repo
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/grafana/grafana/pkg/plugins/log"
|
|
)
|
|
|
|
type Client struct {
|
|
httpClient http.Client
|
|
httpClientNoTimeout http.Client
|
|
retryCount int
|
|
|
|
log log.PrettyLogger
|
|
}
|
|
|
|
func newClient(skipTLSVerify bool, logger log.PrettyLogger) *Client {
|
|
return &Client{
|
|
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
|
|
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0),
|
|
log: logger,
|
|
}
|
|
}
|
|
|
|
func (c *Client) download(_ context.Context, pluginZipURL, checksum string, compatOpts CompatOpts) (*PluginArchive, error) {
|
|
// Create temp file for downloading zip file
|
|
tmpFile, err := os.CreateTemp("", "*.zip")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%v: %w", "failed to create temporary file", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Remove(tmpFile.Name()); err != nil {
|
|
c.log.Warn("Failed to remove temporary file", "file", tmpFile.Name(), "err", err)
|
|
}
|
|
}()
|
|
|
|
c.log.Debugf("Installing plugin from %s", pluginZipURL)
|
|
|
|
err = c.downloadFile(tmpFile, pluginZipURL, checksum, compatOpts)
|
|
if err != nil {
|
|
if err := tmpFile.Close(); err != nil {
|
|
c.log.Warn("Failed to close file", "err", err)
|
|
}
|
|
return nil, fmt.Errorf("%v: %w", "failed to download plugin archive", err)
|
|
}
|
|
|
|
rc, err := zip.OpenReader(tmpFile.Name())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &PluginArchive{
|
|
File: rc,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) downloadFile(tmpFile *os.File, pluginURL, checksum string, compatOpts CompatOpts) (err error) {
|
|
// Try handling URL as a local file path first
|
|
if _, err := os.Stat(pluginURL); err == nil {
|
|
// TODO re-verify
|
|
// We can ignore this gosec G304 warning since `pluginURL` stems from command line flag "pluginUrl". If the
|
|
// user shouldn't be able to read the file, it should be handled through filesystem permissions.
|
|
// nolint:gosec
|
|
f, err := os.Open(pluginURL)
|
|
if err != nil {
|
|
return fmt.Errorf("%v: %w", "Failed to read plugin archive", err)
|
|
}
|
|
defer func() {
|
|
if err := f.Close(); err != nil {
|
|
c.log.Warn("Failed to close file", "err", err)
|
|
}
|
|
}()
|
|
_, err = io.Copy(tmpFile, f)
|
|
if err != nil {
|
|
return fmt.Errorf("%v: %w", "Failed to copy plugin archive", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
c.retryCount = 0
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
c.retryCount++
|
|
if c.retryCount < 3 {
|
|
c.log.Debug("Failed downloading. Will retry once.")
|
|
err = tmpFile.Truncate(0)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, err = tmpFile.Seek(0, 0)
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = c.downloadFile(tmpFile, pluginURL, checksum, compatOpts)
|
|
} else {
|
|
c.retryCount = 0
|
|
failure := fmt.Sprintf("%v", r)
|
|
if failure == "runtime error: makeslice: len out of range" {
|
|
err = fmt.Errorf("corrupt HTTP response from source, please try again")
|
|
} else {
|
|
panic(r)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
u, err := url.Parse(pluginURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Using no timeout here as some plugins can be bigger and smaller timeout would prevent to download a plugin on
|
|
// slow network. As this is CLI operation hanging is not a big of an issue as user can just abort.
|
|
bodyReader, err := c.sendReqNoTimeout(u, compatOpts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := bodyReader.Close(); err != nil {
|
|
c.log.Warn("Failed to close body", "err", err)
|
|
}
|
|
}()
|
|
|
|
w := bufio.NewWriter(tmpFile)
|
|
h := sha256.New()
|
|
if _, err = io.Copy(w, io.TeeReader(bodyReader, h)); err != nil {
|
|
return fmt.Errorf("%v: %w", "failed to compute SHA256 checksum", err)
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return fmt.Errorf("failed to write to %q: %w", tmpFile.Name(), err)
|
|
}
|
|
if len(checksum) > 0 && checksum != fmt.Sprintf("%x", h.Sum(nil)) {
|
|
return fmt.Errorf("expected SHA256 checksum does not match the downloaded archive - please contact security@grafana.com")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) sendReq(url *url.URL, compatOpts CompatOpts) ([]byte, error) {
|
|
req, err := c.createReq(url, compatOpts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bodyReader, err := c.handleResp(res, compatOpts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if err := bodyReader.Close(); err != nil {
|
|
c.log.Warn("Failed to close stream", "err", err)
|
|
}
|
|
}()
|
|
return io.ReadAll(bodyReader)
|
|
}
|
|
|
|
func (c *Client) sendReqNoTimeout(url *url.URL, compatOpts CompatOpts) (io.ReadCloser, error) {
|
|
req, err := c.createReq(url, compatOpts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err := c.httpClientNoTimeout.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.handleResp(res, compatOpts)
|
|
}
|
|
|
|
func (c *Client) createReq(url *url.URL, compatOpts CompatOpts) (*http.Request, error) {
|
|
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("grafana-version", compatOpts.GrafanaVersion)
|
|
req.Header.Set("grafana-os", compatOpts.OS)
|
|
req.Header.Set("grafana-arch", compatOpts.Arch)
|
|
req.Header.Set("User-Agent", "grafana "+compatOpts.GrafanaVersion)
|
|
|
|
return req, err
|
|
}
|
|
|
|
func (c *Client) handleResp(res *http.Response, compatOpts CompatOpts) (io.ReadCloser, error) {
|
|
if res.StatusCode/100 == 4 {
|
|
body, err := io.ReadAll(res.Body)
|
|
defer func() {
|
|
if err := res.Body.Close(); err != nil {
|
|
c.log.Warn("Failed to close response body", "err", err)
|
|
}
|
|
}()
|
|
if err != nil || len(body) == 0 {
|
|
return nil, Response4xxError{StatusCode: res.StatusCode}
|
|
}
|
|
var message string
|
|
var jsonBody map[string]string
|
|
err = json.Unmarshal(body, &jsonBody)
|
|
if err != nil || len(jsonBody["message"]) == 0 {
|
|
message = string(body)
|
|
} else {
|
|
message = jsonBody["message"]
|
|
}
|
|
return nil, Response4xxError{StatusCode: res.StatusCode, Message: message, SystemInfo: compatOpts.String()}
|
|
}
|
|
|
|
if res.StatusCode/100 != 2 {
|
|
return nil, fmt.Errorf("API returned invalid status: %s", res.Status)
|
|
}
|
|
|
|
return res.Body, nil
|
|
}
|
|
|
|
func makeHttpClient(skipTLSVerify bool, timeout time.Duration) http.Client {
|
|
tr := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}).DialContext,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: skipTLSVerify,
|
|
},
|
|
}
|
|
|
|
return http.Client{
|
|
Timeout: timeout,
|
|
Transport: tr,
|
|
}
|
|
}
|