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 }