mirror of https://github.com/grafana/grafana.git
402 lines
12 KiB
Go
402 lines
12 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/google/go-github/v66/github"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
"k8s.io/apiserver/pkg/registry/rest"
|
|
|
|
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
|
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
|
|
)
|
|
|
|
type githubRepository struct {
|
|
logger *slog.Logger
|
|
config *provisioning.Repository
|
|
gh pgh.Client
|
|
}
|
|
|
|
var _ Repository = (*githubRepository)(nil)
|
|
|
|
func NewGitHub(ctx context.Context, config *provisioning.Repository, factory pgh.ClientFactory) *githubRepository {
|
|
return &githubRepository{
|
|
config: config,
|
|
logger: slog.Default().With("logger", "github-repository"),
|
|
gh: factory.New(ctx, config.Spec.GitHub.Token),
|
|
}
|
|
}
|
|
|
|
func (r *githubRepository) Config() *provisioning.Repository {
|
|
return r.config
|
|
}
|
|
|
|
// Validate implements provisioning.Repository.
|
|
func (r *githubRepository) Validate() (list field.ErrorList) {
|
|
gh := r.config.Spec.GitHub
|
|
if gh == nil {
|
|
list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
|
|
return list
|
|
}
|
|
if gh.Owner == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "github", "owner"), "a github repo owner is required"))
|
|
}
|
|
if gh.Repository == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "github", "repository"), "a github repo name is required"))
|
|
}
|
|
if gh.Branch == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required"))
|
|
}
|
|
if gh.Token == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required"))
|
|
}
|
|
if gh.GenerateDashboardPreviews && !gh.BranchWorkflow {
|
|
list = append(list, field.Forbidden(field.NewPath("spec", "github", "token"), "to generate dashboard previews, you must activate the branch workflow"))
|
|
}
|
|
if gh.WebhookURL == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "github", "webhookURL"), "a webhook URL is required"))
|
|
}
|
|
if gh.WebhookSecret == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "github", "webhookSecret"), "a webhook secret is required"))
|
|
}
|
|
|
|
_, err := url.Parse(gh.WebhookURL)
|
|
if err != nil {
|
|
list = append(list, field.Invalid(field.NewPath("spec", "github", "webhookURL"), gh.WebhookURL, "invalid URL"))
|
|
}
|
|
|
|
return list
|
|
}
|
|
|
|
// Test implements provisioning.Repository.
|
|
func (r *githubRepository) Test(ctx context.Context) error {
|
|
return &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Message: "test is not yet implemented",
|
|
Code: http.StatusNotImplemented,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ReadResource implements provisioning.Repository.
|
|
func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*FileInfo, error) {
|
|
if ref == "" {
|
|
ref = r.config.Spec.GitHub.Branch
|
|
}
|
|
owner := r.config.Spec.GitHub.Owner
|
|
repo := r.config.Spec.GitHub.Repository
|
|
|
|
content, _, err := r.gh.GetContents(ctx, owner, repo, filePath, ref)
|
|
if err != nil {
|
|
if errors.Is(err, pgh.ErrResourceNotFound) {
|
|
return nil, &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Message: fmt.Sprintf("file not found; path=%s ref=%s", filePath, ref),
|
|
Code: http.StatusNotFound,
|
|
},
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("get contents: %w", err)
|
|
}
|
|
|
|
data, err := content.GetFileContent()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get content: %w", err)
|
|
}
|
|
return &FileInfo{
|
|
Path: filePath,
|
|
Ref: ref,
|
|
Data: []byte(data),
|
|
Hash: content.GetSHA(),
|
|
}, nil
|
|
}
|
|
|
|
func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
|
|
owner := r.config.Spec.GitHub.Owner
|
|
repo := r.config.Spec.GitHub.Repository
|
|
|
|
err := r.gh.CreateFile(ctx, owner, repo, path, ref, comment, data)
|
|
if errors.Is(err, pgh.ErrResourceAlreadyExists) {
|
|
return &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Message: "file already exists",
|
|
Code: http.StatusConflict,
|
|
},
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
|
|
owner := r.config.Spec.GitHub.Owner
|
|
repo := r.config.Spec.GitHub.Repository
|
|
|
|
file, _, err := r.gh.GetContents(ctx, owner, repo, path, ref)
|
|
if err != nil {
|
|
if errors.Is(err, pgh.ErrResourceNotFound) {
|
|
return &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Message: "file not found",
|
|
Code: http.StatusNotFound,
|
|
},
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("get content before file update: %w", err)
|
|
}
|
|
|
|
if err := r.gh.UpdateFile(ctx, owner, repo, path, ref, comment, file.GetSHA(), data); err != nil {
|
|
return fmt.Errorf("update file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
|
|
owner := r.config.Spec.GitHub.Owner
|
|
repo := r.config.Spec.GitHub.Repository
|
|
|
|
file, _, err := r.gh.GetContents(ctx, owner, repo, path, ref)
|
|
if err != nil {
|
|
if errors.Is(err, pgh.ErrResourceNotFound) {
|
|
return &apierrors.StatusError{
|
|
ErrStatus: metav1.Status{
|
|
Message: "file not found",
|
|
Code: http.StatusNotFound,
|
|
},
|
|
}
|
|
}
|
|
return fmt.Errorf("finding file to delete: %w", err)
|
|
}
|
|
|
|
return r.gh.DeleteFile(ctx, owner, repo, path, ref, comment, file.GetSHA())
|
|
}
|
|
|
|
// Webhook implements provisioning.Repository.
|
|
func (r *githubRepository) Webhook(responder rest.Responder) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
payload, err := github.ValidatePayload(req, []byte(r.config.Spec.GitHub.WebhookSecret))
|
|
if err != nil {
|
|
responder.Error(apierrors.NewUnauthorized("invalid signature"))
|
|
return
|
|
}
|
|
|
|
eventType := github.WebHookType(req)
|
|
event, err := github.ParseWebHook(github.WebHookType(req), payload)
|
|
if err != nil {
|
|
responder.Error(apierrors.NewBadRequest("invalid payload"))
|
|
return
|
|
}
|
|
|
|
switch event := event.(type) {
|
|
case *github.PushEvent:
|
|
if err := r.onPushEvent(req.Context(), event); err != nil {
|
|
responder.Error(err)
|
|
return
|
|
}
|
|
|
|
responder.Object(200, &metav1.Status{
|
|
Message: "event processed",
|
|
Code: http.StatusOK,
|
|
})
|
|
case *github.PingEvent:
|
|
responder.Object(200, &metav1.Status{
|
|
Message: "ping received",
|
|
Code: http.StatusOK,
|
|
})
|
|
default:
|
|
responder.Error(apierrors.NewBadRequest("unsupported event type: " + eventType))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *githubRepository) onPushEvent(ctx context.Context, event *github.PushEvent) error {
|
|
if event.GetRepo() == nil {
|
|
return fmt.Errorf("missing repository in push event")
|
|
}
|
|
|
|
if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository) {
|
|
return fmt.Errorf("repository mismatch")
|
|
}
|
|
|
|
// Skip silently if the event is not for the main/master branch
|
|
// as we cannot configure the webhook to only publish events for the main branch
|
|
if event.GetRef() != fmt.Sprintf("refs/heads/%s", r.config.Spec.GitHub.Branch) {
|
|
r.logger.DebugContext(ctx, "ignoring push event as it is not for the configured branch",
|
|
"ref", event.GetRef())
|
|
return nil
|
|
}
|
|
|
|
beforeRef := event.GetBefore()
|
|
|
|
for _, commit := range event.Commits {
|
|
r.logger.Info("process commit", "commit", commit.GetID(), "message", commit.GetMessage())
|
|
|
|
for _, file := range commit.Added {
|
|
info, err := r.Read(ctx, file, commit.GetID())
|
|
if err != nil {
|
|
return fmt.Errorf("read added resource: %w", err)
|
|
}
|
|
|
|
r.logger.Info("added file", "file", file, "resource", string(info.Data), "commit", commit.GetID())
|
|
}
|
|
|
|
for _, file := range commit.Modified {
|
|
info, err := r.Read(ctx, file, commit.GetID())
|
|
if err != nil {
|
|
return fmt.Errorf("read modified resource: %w", err)
|
|
}
|
|
|
|
r.logger.Info("modified file", "file", file, "resource", string(info.Data), "commit", commit.GetID())
|
|
}
|
|
|
|
for _, file := range commit.Removed {
|
|
info, err := r.Read(ctx, file, beforeRef)
|
|
if err != nil {
|
|
return fmt.Errorf("read removed resource: %w", err)
|
|
}
|
|
|
|
r.logger.Info("removed file", "file", file, "resource", string(info.Data), "commit", commit.GetID())
|
|
}
|
|
|
|
beforeRef = commit.GetID()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *githubRepository) createWebhook(ctx context.Context) error {
|
|
cfg := pgh.WebhookConfig{
|
|
URL: r.config.Spec.GitHub.WebhookURL,
|
|
Secret: r.config.Spec.GitHub.WebhookSecret,
|
|
ContentType: "json",
|
|
Events: []string{"push"},
|
|
Active: true,
|
|
}
|
|
|
|
if err := r.gh.CreateWebhook(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
r.logger.Info("webhook created", "url", cfg.URL)
|
|
return nil
|
|
}
|
|
|
|
func (r *githubRepository) updateWebhook(ctx context.Context, oldRepo *githubRepository) (UndoFunc, error) {
|
|
owner := r.config.Spec.GitHub.Owner
|
|
repoName := r.config.Spec.GitHub.Repository
|
|
|
|
hooks, err := r.gh.ListWebhooks(ctx, owner, repoName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list existing webhooks: %w", err)
|
|
}
|
|
|
|
newCfg := r.config.Spec.GitHub
|
|
oldCfg := oldRepo.Config().Spec.GitHub
|
|
|
|
switch {
|
|
case newCfg.WebhookURL != oldCfg.WebhookURL:
|
|
// In this case we cannot find out out which webhook to update, so we delete the old one and create a new one
|
|
if err := r.createWebhook(ctx); err != nil {
|
|
return nil, fmt.Errorf("create new webhook: %w", err)
|
|
}
|
|
|
|
undoFunc := UndoFunc(func(ctx context.Context) error {
|
|
if err := r.deleteWebhook(ctx); err != nil {
|
|
return fmt.Errorf("revert create new webhook: %w", err)
|
|
}
|
|
|
|
r.logger.Info("create new webhook reverted", "url", newCfg.WebhookURL)
|
|
|
|
return nil
|
|
})
|
|
|
|
if err := oldRepo.deleteWebhook(ctx); err != nil {
|
|
return undoFunc, fmt.Errorf("delete old webhook: %w", err)
|
|
}
|
|
|
|
undoFunc = undoFunc.Chain(ctx, func(ctx context.Context) error {
|
|
if err := oldRepo.createWebhook(ctx); err != nil {
|
|
return fmt.Errorf("revert delete old webhook: %w", err)
|
|
}
|
|
|
|
r.logger.Info("delete old webhook reverted", "url", oldCfg.WebhookURL)
|
|
|
|
return nil
|
|
})
|
|
|
|
return undoFunc, nil
|
|
case newCfg.WebhookSecret != oldCfg.WebhookSecret:
|
|
for _, hook := range hooks {
|
|
if hook.URL == oldCfg.WebhookURL {
|
|
hook.Secret = newCfg.WebhookSecret
|
|
err := r.gh.EditWebhook(ctx, owner, repoName, hook)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update webhook secret: %w", err)
|
|
}
|
|
|
|
r.logger.Info("webhook secret updated", "url", newCfg.WebhookURL)
|
|
|
|
return func(ctx context.Context) error {
|
|
hook.Secret = oldCfg.WebhookSecret
|
|
if err := r.gh.EditWebhook(ctx, owner, repoName, hook); err != nil {
|
|
return fmt.Errorf("revert webhook secret: %w", err)
|
|
}
|
|
|
|
r.logger.Info("webhook secret reverted", "url", oldCfg.WebhookURL)
|
|
return nil
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("webhook not found")
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func (r *githubRepository) deleteWebhook(ctx context.Context) error {
|
|
owner := r.config.Spec.GitHub.Owner
|
|
name := r.config.Spec.GitHub.Repository
|
|
|
|
hooks, err := r.gh.ListWebhooks(ctx, owner, name)
|
|
if err != nil {
|
|
return fmt.Errorf("list existing webhooks: %w", err)
|
|
}
|
|
|
|
for _, hook := range hooks {
|
|
if hook.URL == r.config.Spec.GitHub.WebhookURL {
|
|
if err := r.gh.DeleteWebhook(ctx, owner, name, hook.ID); err != nil {
|
|
return fmt.Errorf("delete webhook: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
r.logger.Info("webhook deleted", "url", r.config.Spec.GitHub.WebhookURL)
|
|
return nil
|
|
}
|
|
|
|
func (r *githubRepository) AfterCreate(ctx context.Context) error {
|
|
return r.createWebhook(ctx)
|
|
}
|
|
|
|
func (r *githubRepository) BeginUpdate(ctx context.Context, old Repository) (UndoFunc, error) {
|
|
oldGitRepo, ok := old.(*githubRepository)
|
|
if !ok {
|
|
return nil, fmt.Errorf("old repository is not a github repository")
|
|
}
|
|
|
|
return r.updateWebhook(ctx, oldGitRepo)
|
|
}
|
|
|
|
func (r *githubRepository) AfterDelete(ctx context.Context) error {
|
|
return r.deleteWebhook(ctx)
|
|
}
|