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

     1  package vcs
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"net/http"
     8  	"time"
     9  
    10  	"connectrpc.com/connect"
    11  	"github.com/go-kit/log"
    12  	giturl "github.com/kubescape/go-git-url"
    13  	"github.com/kubescape/go-git-url/apis"
    14  	"github.com/opentracing/opentracing-go"
    15  	"github.com/prometheus/client_golang/prometheus"
    16  	"golang.org/x/oauth2"
    17  
    18  	vcsv1 "github.com/grafana/pyroscope/api/gen/proto/go/vcs/v1"
    19  	"github.com/grafana/pyroscope/api/gen/proto/go/vcs/v1/vcsv1connect"
    20  	"github.com/grafana/pyroscope/pkg/frontend/vcs/client"
    21  	"github.com/grafana/pyroscope/pkg/frontend/vcs/config"
    22  	"github.com/grafana/pyroscope/pkg/frontend/vcs/source"
    23  )
    24  
    25  var _ vcsv1connect.VCSServiceHandler = (*Service)(nil)
    26  
    27  type Service struct {
    28  	logger     log.Logger
    29  	httpClient *http.Client
    30  }
    31  
    32  func New(logger log.Logger, reg prometheus.Registerer) *Service {
    33  	httpClient := client.InstrumentedHTTPClient(logger, reg)
    34  
    35  	return &Service{
    36  		logger:     logger,
    37  		httpClient: httpClient,
    38  	}
    39  }
    40  
    41  func (q *Service) GithubApp(ctx context.Context, req *connect.Request[vcsv1.GithubAppRequest]) (*connect.Response[vcsv1.GithubAppResponse], error) {
    42  	sp, _ := opentracing.StartSpanFromContext(ctx, "GithubApp")
    43  	defer sp.Finish()
    44  
    45  	err := isGitHubIntegrationConfigured()
    46  	if err != nil {
    47  		q.logger.Log("err", err, "msg", "GitHub integration is not configured")
    48  		return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("GitHub integration is not configured"))
    49  	}
    50  
    51  	return connect.NewResponse(&vcsv1.GithubAppResponse{
    52  		ClientID:    githubAppClientID,
    53  		CallbackURL: githubAppCallbackURL,
    54  	}), nil
    55  }
    56  
    57  func (q *Service) GithubLogin(ctx context.Context, req *connect.Request[vcsv1.GithubLoginRequest]) (*connect.Response[vcsv1.GithubLoginResponse], error) {
    58  	sp, ctx := opentracing.StartSpanFromContext(ctx, "GithubLogin")
    59  	defer sp.Finish()
    60  
    61  	cfg, err := githubOAuthConfig()
    62  	if err != nil {
    63  		q.logger.Log("err", err, "msg", "failed to get GitHub OAuth config")
    64  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to authorize with GitHub"))
    65  	}
    66  
    67  	encryptionKey, err := deriveEncryptionKeyForContext(ctx)
    68  	if err != nil {
    69  		q.logger.Log("err", err, "msg", "failed to derive encryption key")
    70  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to authorize with GitHub"))
    71  	}
    72  
    73  	token, err := cfg.Exchange(ctx, req.Msg.AuthorizationCode)
    74  	if err != nil {
    75  		q.logger.Log("err", err, "msg", "failed to exchange authorization code with GitHub")
    76  		return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("failed to authorize with GitHub"))
    77  	}
    78  
    79  	cookie, err := encodeTokenInCookie(token, encryptionKey)
    80  	if err != nil {
    81  		q.logger.Log("err", err, "msg", "failed to encode deprecated GitHub OAuth token")
    82  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to authorize with GitHub"))
    83  	}
    84  
    85  	encoded, err := encryptToken(token, encryptionKey)
    86  	if err != nil {
    87  		q.logger.Log("err", err, "msg", "failed to encode GitHub OAuth token")
    88  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to authorize with GitHub"))
    89  	}
    90  
    91  	res := &vcsv1.GithubLoginResponse{
    92  		Cookie:                cookie.String(),
    93  		Token:                 encoded,
    94  		TokenExpiresAt:        token.Expiry.UnixMilli(),
    95  		RefreshTokenExpiresAt: time.Now().Add(githubRefreshExpiryDuration).UnixMilli(),
    96  	}
    97  	return connect.NewResponse(res), nil
    98  }
    99  
   100  func (q *Service) GithubRefresh(ctx context.Context, req *connect.Request[vcsv1.GithubRefreshRequest]) (*connect.Response[vcsv1.GithubRefreshResponse], error) {
   101  	sp, ctx := opentracing.StartSpanFromContext(ctx, "GithubRefresh")
   102  	defer sp.Finish()
   103  
   104  	token, err := tokenFromRequest(ctx, req)
   105  	if err != nil {
   106  		q.logger.Log("err", err, "msg", "failed to extract token from request")
   107  		return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("invalid token"))
   108  	}
   109  
   110  	githubRequest, err := buildGithubRefreshRequest(ctx, token)
   111  	if err != nil {
   112  		q.logger.Log("err", err, "msg", "failed to extract token from request")
   113  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to refresh token"))
   114  	}
   115  
   116  	githubToken, err := refreshGithubToken(githubRequest, q.httpClient)
   117  	if err != nil {
   118  		q.logger.Log("err", err, "msg", "failed to refresh token with GitHub")
   119  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to refresh token"))
   120  	}
   121  
   122  	newToken := githubToken.toOAuthToken()
   123  
   124  	derivedKey, err := deriveEncryptionKeyForContext(ctx)
   125  	if err != nil {
   126  		q.logger.Log("err", err, "msg", "failed to derive encryption key")
   127  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to process token"))
   128  	}
   129  
   130  	cookie, err := encodeTokenInCookie(newToken, derivedKey)
   131  	if err != nil {
   132  		q.logger.Log("err", err, "msg", "failed to encode deprecated GitHub OAuth token")
   133  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to refresh token"))
   134  	}
   135  
   136  	encoded, err := encryptToken(newToken, derivedKey)
   137  	if err != nil {
   138  		q.logger.Log("err", err, "msg", "failed to encode GitHub OAuth token")
   139  		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to refresh token"))
   140  	}
   141  
   142  	res := &vcsv1.GithubRefreshResponse{
   143  		Cookie:                cookie.String(),
   144  		Token:                 encoded,
   145  		TokenExpiresAt:        token.Expiry.UnixMilli(),
   146  		RefreshTokenExpiresAt: time.Now().Add(githubRefreshExpiryDuration).UnixMilli(),
   147  	}
   148  	return connect.NewResponse(res), nil
   149  }
   150  
   151  func (q *Service) GetFile(ctx context.Context, req *connect.Request[vcsv1.GetFileRequest]) (*connect.Response[vcsv1.GetFileResponse], error) {
   152  	sp, ctx := opentracing.StartSpanFromContext(ctx, "GetFile")
   153  	defer sp.Finish()
   154  	sp.SetTag("repository_url", req.Msg.RepositoryURL)
   155  	sp.SetTag("local_path", req.Msg.LocalPath)
   156  	sp.SetTag("function_name", req.Msg.FunctionName)
   157  	sp.SetTag("root_path", req.Msg.RootPath)
   158  	sp.SetTag("ref", req.Msg.Ref)
   159  
   160  	token, err := tokenFromRequest(ctx, req)
   161  	if err != nil {
   162  		q.logger.Log("err", err, "msg", "failed to extract token from request")
   163  		return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("invalid token"))
   164  	}
   165  
   166  	err = rejectExpiredToken(token)
   167  	if err != nil {
   168  		return nil, err
   169  	}
   170  
   171  	// initialize and parse the git repo URL
   172  	gitURL, err := giturl.NewGitURL(req.Msg.RepositoryURL)
   173  	if err != nil {
   174  		return nil, connect.NewError(connect.CodeInvalidArgument, err)
   175  	}
   176  
   177  	if gitURL.GetProvider() != apis.ProviderGitHub.String() {
   178  		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("only GitHub repositories are supported"))
   179  	}
   180  
   181  	// todo: we can support multiple provider: bitbucket, gitlab, etc.
   182  	ghClient, err := client.GithubClient(ctx, token, q.httpClient)
   183  	if err != nil {
   184  		return nil, err
   185  	}
   186  
   187  	file, err := source.NewFileFinder(
   188  		ghClient,
   189  		gitURL,
   190  		config.FileSpec{
   191  			Path:         req.Msg.LocalPath,
   192  			FunctionName: req.Msg.FunctionName,
   193  		},
   194  		req.Msg.RootPath,
   195  		req.Msg.Ref,
   196  		http.DefaultClient,
   197  		log.With(q.logger, "repo", gitURL.GetRepoName()),
   198  	).Find(ctx)
   199  	if err != nil {
   200  		if errors.Is(err, client.ErrNotFound) {
   201  			return nil, connect.NewError(connect.CodeNotFound, err)
   202  		}
   203  		return nil, err
   204  	}
   205  	return connect.NewResponse(file), nil
   206  }
   207  
   208  func (q *Service) GetCommit(ctx context.Context, req *connect.Request[vcsv1.GetCommitRequest]) (*connect.Response[vcsv1.GetCommitResponse], error) {
   209  	sp, ctx := opentracing.StartSpanFromContext(ctx, "GetCommit")
   210  	defer sp.Finish()
   211  	sp.SetTag("repository_url", req.Msg.RepositoryURL)
   212  	sp.SetTag("ref", req.Msg.Ref)
   213  
   214  	token, err := tokenFromRequest(ctx, req)
   215  	if err != nil {
   216  		q.logger.Log("err", err, "msg", "failed to extract token from request")
   217  		return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("invalid token"))
   218  	}
   219  
   220  	err = rejectExpiredToken(token)
   221  	if err != nil {
   222  		return nil, err
   223  	}
   224  
   225  	gitURL, err := giturl.NewGitURL(req.Msg.RepositoryURL)
   226  	if err != nil {
   227  		return nil, connect.NewError(connect.CodeInvalidArgument, err)
   228  	}
   229  
   230  	if gitURL.GetProvider() != apis.ProviderGitHub.String() {
   231  		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("only GitHub repositories are supported"))
   232  	}
   233  
   234  	ghClient, err := client.GithubClient(ctx, token, q.httpClient)
   235  	if err != nil {
   236  		return nil, err
   237  	}
   238  
   239  	owner := gitURL.GetOwnerName()
   240  	repo := gitURL.GetRepoName()
   241  	ref := req.Msg.GetRef()
   242  
   243  	commit, err := tryGetCommit(ctx, ghClient, owner, repo, ref)
   244  	if err != nil {
   245  		return nil, err
   246  	}
   247  
   248  	return connect.NewResponse(&vcsv1.GetCommitResponse{
   249  		Message: commit.GetMessage(),
   250  		Author:  commit.GetAuthor(),
   251  		Date:    commit.GetDate(),
   252  		Sha:     commit.GetSha(),
   253  		URL:     commit.GetURL(),
   254  	}), nil
   255  }
   256  
   257  func (q *Service) GetCommits(ctx context.Context, req *connect.Request[vcsv1.GetCommitsRequest]) (*connect.Response[vcsv1.GetCommitsResponse], error) {
   258  	sp, ctx := opentracing.StartSpanFromContext(ctx, "GetCommits")
   259  	defer sp.Finish()
   260  
   261  	token, err := tokenFromRequest(ctx, req)
   262  	if err != nil {
   263  		q.logger.Log("err", err, "msg", "failed to extract token from request")
   264  		return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("invalid token"))
   265  	}
   266  
   267  	err = rejectExpiredToken(token)
   268  	if err != nil {
   269  		return nil, err
   270  	}
   271  
   272  	gitURL, err := giturl.NewGitURL(req.Msg.RepositoryUrl)
   273  	if err != nil {
   274  		return nil, connect.NewError(connect.CodeInvalidArgument, err)
   275  	}
   276  
   277  	if gitURL.GetProvider() != apis.ProviderGitHub.String() {
   278  		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("only GitHub repositories are supported"))
   279  	}
   280  
   281  	ghClient, err := client.GithubClient(ctx, token, q.httpClient)
   282  	if err != nil {
   283  		return nil, err
   284  	}
   285  
   286  	owner := gitURL.GetOwnerName()
   287  	repo := gitURL.GetRepoName()
   288  	refs := req.Msg.Refs
   289  
   290  	commits, failedFetches, err := getCommits(ctx, ghClient, owner, repo, refs)
   291  	if err != nil {
   292  		q.logger.Log("err", err, "msg", "failed to get any commits", "owner", owner, "repo", repo)
   293  		return nil, err
   294  	}
   295  
   296  	if len(failedFetches) > 0 {
   297  		q.logger.Log("warn", "partial success fetching commits", "owner", owner, "repo", repo, "successCount", len(commits), "failureCount", len(failedFetches))
   298  		for _, fetchErr := range failedFetches {
   299  			q.logger.Log("err", fetchErr, "msg", "failed to fetch commit")
   300  		}
   301  	}
   302  
   303  	return connect.NewResponse(&vcsv1.GetCommitsResponse{Commits: commits}), nil
   304  }
   305  
   306  func rejectExpiredToken(token *oauth2.Token) error {
   307  	if time.Now().After(token.Expiry) {
   308  		return connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("token is expired"))
   309  	}
   310  	return nil
   311  }