Files
Roberto Jiménez Sánchez 047499a363 Provisioning: introduce concept of provisioning extras (#104981)
* Spike: Extras

* Attempt to wire it up

* Hack

* Fix issue with jobs

* Wire more things up

* Fix more wiring stuff

* Remove webhook secret key from main registration

* Move secret encryption also outside register

* Add TODOs in code

* Add more explanations

* Move connectors to different package

* Move pull request job into webhooks

* Separate registration

* Remove duplicate files

* Fix missing function

* Extract webhook repository logic out of the core github repository

* Use status patcher in webhook connector

* Fix change in go mod

* Change hooks signature

* Remove TODOs

* Remove Webhook methos from go-git

* Remove leftover

* Fix mistake in OpenAPI spec

* Fix some tests

* Fix some issues

* Fix linting
2025-05-13 09:50:43 +02:00

194 lines
6.0 KiB
Go

package webhooks
import (
"context"
"fmt"
"net/http"
"time"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/kube-openapi/pkg/spec3"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/identity"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest"
)
// Webhook endpoint max size (25MB)
// See https://docs.github.com/en/webhooks/webhook-events-and-payloads
const webhookMaxBodySize = 25 * 1024 * 1024
// This only works for github right now
type webhookConnector struct {
webhooksEnabled bool
core *provisioningapis.APIBuilder
renderer pullrequest.ScreenshotRenderer
}
func NewWebhookConnector(
webhooksEnabled bool,
// TODO: use interface for this
core *provisioningapis.APIBuilder,
renderer pullrequest.ScreenshotRenderer,
) *webhookConnector {
return &webhookConnector{
webhooksEnabled: webhooksEnabled,
core: core,
renderer: renderer,
}
}
func (*webhookConnector) New() runtime.Object {
return &provisioning.WebhookResponse{}
}
func (*webhookConnector) Destroy() {}
func (*webhookConnector) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
}
func (*webhookConnector) ProducesObject(verb string) any {
return &provisioning.WebhookResponse{}
}
func (*webhookConnector) ConnectMethods() []string {
return []string{
http.MethodPost,
http.MethodGet, // only useful for browser testing, should be removed
}
}
func (*webhookConnector) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (s *webhookConnector) Authorize(ctx context.Context, a authorizer.Attributes) (decision authorizer.Decision, reason string, err error) {
if provisioning.RepositoryResourceInfo.GetName() == a.GetResource() && a.GetSubresource() == "webhook" {
// When the resource is a webhook, we'll deal with permissions manually by checking signatures or similar in the webhook handler.
// The user in this context is usually an anonymous user, but may also be an authenticated synthetic check by the Grafana instance's operator as well.
// For context on the anonymous user, check the authn/clients/provisioning.go file.
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionNoOpinion, "", nil
}
func (s *webhookConnector) UpdateStorage(storage map[string]rest.Storage) error {
storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = s
return nil
}
func (s *webhookConnector) PostProcessOpenAPI(oas *spec3.OpenAPI) error {
root := "/apis/" + s.core.GetGroupVersion().String() + "/"
repoprefix := root + "namespaces/{namespace}/repositories/{name}"
sub := oas.Paths.Paths[repoprefix+"/webhook"]
if sub != nil && sub.Get != nil {
sub.Post.Description = "Currently only supports github webhooks"
}
return nil
}
func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
namespace := request.NamespaceValue(ctx)
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
return nil, err
}
// Get the repository with the worker identity (since the request user is likely anonymous)
repo, err := s.core.GetRepository(ctx, name)
if err != nil {
return nil, err
}
return provisioningapis.WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := logging.FromContext(r.Context()).With("logger", "webhook-connector", "repo", name)
ctx := logging.Context(r.Context(), logger)
if !s.webhooksEnabled {
responder.Error(errors.NewBadRequest("webhooks are not enabled"))
return
}
hooks, ok := repo.(WebhookRepository)
if !ok {
responder.Error(errors.NewBadRequest("the repository does not support webhooks"))
return
}
// Limit the webhook request body size
r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodySize)
rsp, err := hooks.Webhook(ctx, r)
if err != nil {
responder.Error(err)
return
}
if rsp == nil {
responder.Error(fmt.Errorf("expecting a response"))
return
}
if err := s.updateLastEvent(ctx, repo); err != nil {
// Continue processing as this is non-critical; the update is purely informational
logger.Error("failed to update last event", "error", err)
}
if rsp.Job != nil {
rsp.Job.Repository = name
job, err := s.core.GetJobQueue().Insert(ctx, namespace, *rsp.Job)
if err != nil {
responder.Error(err)
return
}
responder.Object(rsp.Code, job)
return
}
responder.Object(rsp.Code, rsp)
}), 30*time.Second), nil
}
// updateLastEvent updates the last event time for the webhook
// This is to provide some visibility that the webhook is still active and working
// It's not a good idea to update the webhook status too often, so we only update it if it's been a while
func (s *webhookConnector) updateLastEvent(ctx context.Context, repo repository.Repository) error {
patcher := s.core.GetStatusPatcher()
if patcher == nil {
// This would only happen if we wired things up incorrectly
return fmt.Errorf("status patcher is nil")
}
lastEvent := time.UnixMilli(repo.Config().Status.Webhook.LastEvent)
eventAge := time.Since(lastEvent)
if repo.Config().Status.Webhook != nil && (eventAge > time.Minute) {
patchOp := map[string]interface{}{
"op": "replace",
"path": "/status/webhook/lastEvent",
"value": time.Now().UnixMilli(),
}
if err := patcher.Patch(ctx, repo.Config(), patchOp); err != nil {
return fmt.Errorf("patch status: %w", err)
}
}
return nil
}
var (
_ rest.Storage = (*webhookConnector)(nil)
_ rest.Connecter = (*webhookConnector)(nil)
_ rest.StorageMetadata = (*webhookConnector)(nil)
)