github.com/grafana/pyroscope@v1.18.0/pkg/frontend/vcs/source/find.go (about)

     1  package source
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"path/filepath"
    10  	"strings"
    11  
    12  	"connectrpc.com/connect"
    13  	"github.com/go-kit/log"
    14  	"github.com/go-kit/log/level"
    15  	giturl "github.com/kubescape/go-git-url"
    16  	"github.com/opentracing/opentracing-go"
    17  
    18  	vcsv1 "github.com/grafana/pyroscope/api/gen/proto/go/vcs/v1"
    19  	"github.com/grafana/pyroscope/pkg/frontend/vcs/client"
    20  	"github.com/grafana/pyroscope/pkg/frontend/vcs/config"
    21  )
    22  
    23  type VCSClient interface {
    24  	GetFile(ctx context.Context, req client.FileRequest) (client.File, error)
    25  }
    26  
    27  // FileFinder finds a file in a vcs repository.
    28  type FileFinder struct {
    29  	file          config.FileSpec
    30  	ref, rootPath string
    31  	repo          giturl.IGitURL
    32  
    33  	config     *config.PyroscopeConfig
    34  	client     VCSClient
    35  	httpClient *http.Client
    36  	logger     log.Logger
    37  }
    38  
    39  // NewFileFinder returns a new FileFinder.
    40  func NewFileFinder(client VCSClient, repo giturl.IGitURL, file config.FileSpec, rootPath, ref string, httpClient *http.Client, logger log.Logger) *FileFinder {
    41  	if ref == "" {
    42  		ref = "HEAD"
    43  	}
    44  	return &FileFinder{
    45  		client:     client,
    46  		logger:     logger,
    47  		repo:       repo,
    48  		file:       file,
    49  		rootPath:   rootPath,
    50  		ref:        ref,
    51  		httpClient: httpClient,
    52  	}
    53  }
    54  
    55  // Find returns the file content and URL.
    56  func (ff *FileFinder) Find(ctx context.Context) (*vcsv1.GetFileResponse, error) {
    57  	// first try to gather the config
    58  	ff.loadConfig(ctx)
    59  
    60  	// without config we are done here
    61  	if ff.config == nil {
    62  		return ff.findFallback(ctx)
    63  	}
    64  
    65  	// find matching mappings
    66  	mapping := ff.config.FindMapping(ff.file)
    67  	if mapping == nil {
    68  		return ff.findFallback(ctx)
    69  	}
    70  
    71  	switch config.Language(mapping.Language) {
    72  	case config.LanguageGo:
    73  		return ff.findGoFile(ctx, mapping)
    74  	case config.LanguageJava:
    75  		return ff.findJavaFile(ctx, mapping)
    76  	case config.LanguagePython:
    77  		return ff.findPythonFile(ctx, mapping)
    78  	// todo: add more languages support
    79  	default:
    80  		return ff.findFallback(ctx)
    81  	}
    82  }
    83  
    84  func (ff FileFinder) findFallback(ctx context.Context) (*vcsv1.GetFileResponse, error) {
    85  	switch filepath.Ext(ff.file.Path) {
    86  	case ExtGo:
    87  		return ff.findGoFile(ctx)
    88  	case ExtPython:
    89  		return ff.findPythonFile(ctx)
    90  	case ExtAsm: // Note: When adding wider language support this needs to be revisited
    91  		return ff.findGoFile(ctx)
    92  	// todo: add more languages support
    93  	default:
    94  		// by default we return the file content at the given path without any processing.
    95  		return ff.fetchRepoFile(ctx, ff.file.Path, ff.ref)
    96  	}
    97  }
    98  
    99  // loadConfig attempts to load .pyroscope.yaml from the repository root
   100  func (ff *FileFinder) loadConfig(ctx context.Context) {
   101  	sp, ctx := opentracing.StartSpanFromContext(ctx, "FileFinder.loadConfig")
   102  	defer sp.Finish()
   103  
   104  	configPath := config.PyroscopeConfigPath
   105  	if ff.rootPath != "" {
   106  		configPath = filepath.Join(ff.rootPath, config.PyroscopeConfigPath)
   107  	}
   108  
   109  	file, err := ff.client.GetFile(ctx, client.FileRequest{
   110  		Owner: ff.repo.GetOwnerName(),
   111  		Repo:  ff.repo.GetRepoName(),
   112  		Path:  configPath,
   113  		Ref:   ff.ref,
   114  	})
   115  	if err != nil {
   116  		// Config is optional, so just log and continue
   117  		level.Debug(ff.logger).Log("msg", "no .pyroscope.yaml found", "path", configPath)
   118  		return
   119  	}
   120  	sp.SetTag("config.url", file.URL)
   121  	sp.SetTag("config", file.Content)
   122  
   123  	cfg, err := config.ParsePyroscopeConfig([]byte(file.Content))
   124  	if err != nil {
   125  		level.Warn(ff.logger).Log("msg", "failed to parse .pyroscope.yaml", "err", err)
   126  		return
   127  	}
   128  
   129  	ff.config = cfg
   130  	level.Debug(ff.logger).Log("msg", "loaded .pyroscope.yaml", "url", file.URL, "mappings", len(cfg.SourceCode.Mappings))
   131  	sp.SetTag("config.source_code.mappings_count", len(cfg.SourceCode.Mappings))
   132  
   133  }
   134  
   135  // fetchRepoFile fetches the file content from the configured repository.
   136  func (arg FileFinder) fetchRepoFile(ctx context.Context, path, ref string) (*vcsv1.GetFileResponse, error) {
   137  	if arg.rootPath != "" {
   138  		path = filepath.Join(arg.rootPath, path)
   139  	}
   140  	content, err := arg.client.GetFile(ctx, client.FileRequest{
   141  		Owner: arg.repo.GetOwnerName(),
   142  		Repo:  arg.repo.GetRepoName(),
   143  		Path:  strings.TrimLeft(path, "/"),
   144  		Ref:   ref,
   145  	})
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  	return newFileResponse(content.Content, content.URL)
   150  }
   151  
   152  // fetchURL fetches the file content from the given URL.
   153  func (ff FileFinder) fetchURL(ctx context.Context, url string, decodeBase64 bool) (*vcsv1.GetFileResponse, error) {
   154  	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  	resp, err := ff.httpClient.Do(req) // todo: use a custom client with timeout
   159  	if err != nil {
   160  		return nil, err
   161  	}
   162  	defer resp.Body.Close()
   163  	if resp.StatusCode != http.StatusOK {
   164  		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("failed to fetch %s: %s", url, resp.Status))
   165  	}
   166  	content, err := io.ReadAll(resp.Body)
   167  	if err != nil {
   168  		return nil, err
   169  	}
   170  	if !decodeBase64 {
   171  		return newFileResponse(string(content), url)
   172  	}
   173  	decoded, err := base64.StdEncoding.DecodeString(string(content))
   174  	if err != nil {
   175  		return nil, err
   176  	}
   177  	return newFileResponse(string(decoded), url)
   178  }
   179  
   180  func newFileResponse(content, url string) (*vcsv1.GetFileResponse, error) {
   181  	return &vcsv1.GetFileResponse{
   182  		Content: base64.StdEncoding.EncodeToString([]byte(content)),
   183  		URL:     url,
   184  	}, nil
   185  }
   186  
   187  func (ff FileFinder) fetchMappingFile(ctx context.Context, m *config.MappingConfig, path string) (*vcsv1.GetFileResponse, error) {
   188  	if s := m.Source.Local; s != nil {
   189  		if s.Path != "" {
   190  			path = filepath.Join(s.Path, path)
   191  		}
   192  		return ff.fetchRepoFile(ctx, path, ff.ref)
   193  	}
   194  	if s := m.Source.GitHub; s != nil {
   195  		if s.Path != "" {
   196  			path = filepath.Join(s.Path, path)
   197  		}
   198  		content, err := ff.client.GetFile(ctx, client.FileRequest{
   199  			Owner: s.Owner,
   200  			Repo:  s.Repo,
   201  			Ref:   s.Ref,
   202  			Path:  path,
   203  		})
   204  		if err != nil {
   205  			return nil, err
   206  		}
   207  		return newFileResponse(content.Content, content.URL)
   208  	}
   209  	return nil, fmt.Errorf("no supported source provided, file not resolvable")
   210  }