grafana/apps/provisioning/pkg/connection/github/client.go

260 lines
7.8 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/google/go-github/v82/github"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
// API errors that we need to convey after parsing real GH errors (or faking them).
var (
ErrAuthentication = apierrors.NewUnauthorized("authentication failed")
ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
ErrNotFound = errors.New("not found")
ErrUnprocessableEntity = errors.New("unprocessable entity")
)
//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter
type Client interface {
GetApp(ctx context.Context) (App, error)
GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error)
ListInstallationRepositories(ctx context.Context) ([]Repository, error)
CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error)
}
// Repository represents a GitHub repository accessible through an installation.
type Repository struct {
// Name of the repository
Name string
// Owner is the user or organization that owns the repository
Owner string
// URL of the repository (HTML URL)
URL string
}
// App represents a Github App.
type App struct {
// ID represents the GH app ID.
ID int64
// Slug represents the GH app slug.
Slug string
// Owner represents the GH account/org owning the app
Owner string
// Permissions granted to the GitHub App
Permissions Permissions
}
type Permission int
const (
PermissionNone Permission = iota
PermissionRead
PermissionWrite
)
// Permissions represents the permissions granted to a GitHub Apps and their installations.
type Permissions struct {
Contents Permission
Metadata Permission
PullRequests Permission
Webhooks Permission
}
// AppInstallation represents a Github App Installation.
type AppInstallation struct {
// ID represents the GH installation ID.
ID int64
// Whether the installation is enabled or not.
Enabled bool
// Permissions granted to this installation.
// These may differ from App permissions if the installation owner has not yet accepted
// the App's updated permissions on GitHub.
Permissions Permissions
}
// InstallationToken represents a Github App Installation Access Token.
type InstallationToken struct {
// Token is the access token value.
Token string
// ExpiresAt is the expiration time of the token.
ExpiresAt time.Time
}
type githubClient struct {
gh *github.Client
}
func NewClient(client *github.Client) Client {
return &githubClient{gh: client}
}
// GetApp gets the app by using the given token.
func (r *githubClient) GetApp(ctx context.Context) (App, error) {
app, _, err := r.gh.Apps.Get(ctx, "")
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response != nil {
switch ghErr.Response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return App{}, ErrAuthentication
case http.StatusNotFound:
return App{}, fmt.Errorf("app: %w", ErrNotFound)
case http.StatusServiceUnavailable:
return App{}, ErrServiceUnavailable
}
}
return App{}, err
}
return App{
ID: app.GetID(),
Slug: app.GetSlug(),
Owner: app.GetOwner().GetLogin(),
Permissions: Permissions{
Contents: toPermission(app.GetPermissions().GetContents()),
Metadata: toPermission(app.GetPermissions().GetMetadata()),
PullRequests: toPermission(app.GetPermissions().GetPullRequests()),
Webhooks: toPermission(app.GetPermissions().GetRepositoryHooks()),
},
}, nil
}
// GetAppInstallation gets the installation of the app related to the given token.
func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) {
id, err := strconv.Atoi(installationID)
if err != nil {
return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID)
}
installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id))
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response != nil {
switch ghErr.Response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return AppInstallation{}, ErrAuthentication
case http.StatusNotFound:
return AppInstallation{}, fmt.Errorf("installation: %w", ErrNotFound)
case http.StatusServiceUnavailable:
return AppInstallation{}, ErrServiceUnavailable
}
}
return AppInstallation{}, err
}
return AppInstallation{
ID: installation.GetID(),
Enabled: installation.GetSuspendedAt().IsZero(),
Permissions: Permissions{
Contents: toPermission(installation.GetPermissions().GetContents()),
Metadata: toPermission(installation.GetPermissions().GetMetadata()),
PullRequests: toPermission(installation.GetPermissions().GetPullRequests()),
Webhooks: toPermission(installation.GetPermissions().GetRepositoryHooks()),
},
}, nil
}
func toPermission(permissions string) Permission {
switch permissions {
case "read":
return PermissionRead
case "write":
return PermissionWrite
default:
return PermissionNone
}
}
const (
maxRepositories = 1000 // Maximum number of repositories to fetch
)
// ListInstallationRepositories lists all repositories accessible by the specified GitHub App installation.
func (r *githubClient) ListInstallationRepositories(ctx context.Context) ([]Repository, error) {
var allRepos []Repository
opts := &github.ListOptions{
Page: 1,
PerPage: 100,
}
for {
result, resp, err := r.gh.Apps.ListRepos(ctx, opts)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, ErrServiceUnavailable
}
return nil, fmt.Errorf("list repositories: %w", err)
}
for _, repo := range result.Repositories {
allRepos = append(allRepos, Repository{
Name: repo.GetName(),
Owner: repo.GetOwner().GetLogin(),
URL: repo.GetHTMLURL(),
})
}
// Check if we've exceeded the maximum allowed repositories
if len(allRepos) > maxRepositories {
return nil, fmt.Errorf("too many repositories to fetch (more than %d)", maxRepositories)
}
// If there are no more pages, break
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allRepos, nil
}
// CreateInstallationAccessToken creates an installation access token scoped to a specific repository.
// Based on https://docs.github.com/en/rest/apps/apps?apiVersion=latest#create-an-installation-access-token-for-an-app
// the installation access token will last one hour from the time it is created.
func (r *githubClient) CreateInstallationAccessToken(ctx context.Context, installationID string, repo string) (InstallationToken, error) {
id, err := strconv.Atoi(installationID)
if err != nil {
return InstallationToken{}, fmt.Errorf("invalid installation ID: %s", installationID)
}
var opts *github.InstallationTokenOptions
if repo != "" {
opts = &github.InstallationTokenOptions{
Repositories: []string{repo},
}
}
token, _, err := r.gh.Apps.CreateInstallationToken(ctx, int64(id), opts)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
switch ghErr.Response.StatusCode {
case http.StatusServiceUnavailable:
return InstallationToken{}, ErrServiceUnavailable
case http.StatusUnauthorized, http.StatusForbidden:
return InstallationToken{}, ErrAuthentication
case http.StatusNotFound:
// Not Found is returned by this API when the given installation is not present.
return InstallationToken{}, fmt.Errorf("installation: %w", ErrNotFound)
case http.StatusUnprocessableEntity:
return InstallationToken{}, fmt.Errorf("%s: %w", ghErr.Message, ErrUnprocessableEntity)
}
}
return InstallationToken{}, err
}
return InstallationToken{
Token: token.GetToken(),
ExpiresAt: token.GetExpiresAt().Time,
}, nil
}