diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 1cd3f6f915b..c2941d6b725 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -37,9 +37,17 @@ type GitHubRepositoryConfig struct { Owner string `json:"owner,omitempty"` // The name of the repository (e.g. test in `example/test` or `https://github.com/example/test`). Repository string `json:"repository,omitempty"` + // The branch to use in the repository. + // By default, this is the main branch. + Branch string `json:"branch,omitempty"` // Token for accessing the repository. // TODO: this should be part of secrets and a simple reference. Token string `json:"token,omitempty"` + // WebhookURL is the URL to send webhooks events to. + // By default, the system will generate a URL for you but you can use this one to run + // grafana locally and test the webhooks. + // TODO: This should be generated by the controller and not user-provided. + WebhookURL string `json:"webhookURL,omitempty"` // WebhookSecret is the secret used to validate incoming webhooks. // TODO: this should be generated by the controller and not user-provided. WebhookSecret string `json:"webhookSecret,omitempty"` diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 104bd4d0c9a..2c53937a56e 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -47,6 +47,13 @@ func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.Ref Format: "", }, }, + "branch": { + SchemaProps: spec.SchemaProps{ + Description: "The branch to use in the repository. By default, this is the main branch.", + Type: []string{"string"}, + Format: "", + }, + }, "token": { SchemaProps: spec.SchemaProps{ Description: "Token for accessing the repository.", @@ -54,6 +61,13 @@ func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.Ref Format: "", }, }, + "webhookURL": { + SchemaProps: spec.SchemaProps{ + Description: "WebhookURL is the URL to send webhooks events to. By default, the system will generate a URL for you but you can use this one to run grafana locally and test the webhooks.", + Type: []string{"string"}, + Format: "", + }, + }, "webhookSecret": { SchemaProps: spec.SchemaProps{ Description: "WebhookSecret is the secret used to validate incoming webhooks.", diff --git a/pkg/registry/apis/provisioning/github.go b/pkg/registry/apis/provisioning/github.go index f4b6d68f53e..6392fd96489 100644 --- a/pkg/registry/apis/provisioning/github.go +++ b/pkg/registry/apis/provisioning/github.go @@ -2,26 +2,41 @@ package provisioning import ( "context" + "errors" "fmt" + "log/slog" "net/http" "net/url" - "strings" "github.com/google/go-github/v66/github" "golang.org/x/oauth2" - "k8s.io/apimachinery/pkg/api/errors" + 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" ) type githubRepository struct { - config *provisioning.Repository + logger *slog.Logger + config *provisioning.Repository + githubClient *github.Client } -func newGithubRepository(config *provisioning.Repository) *githubRepository { - return &githubRepository{config} +func newGithubRepository(ctx context.Context, config *provisioning.Repository) *githubRepository { + tokenSrc := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: config.Spec.GitHub.Token}, + ) + tokenClient := oauth2.NewClient(ctx, tokenSrc) + githubClient := github.NewClient(tokenClient) + + return &githubRepository{ + config: config, + githubClient: githubClient, + logger: slog.Default().With("logger", "github-repository"), + } + } func (r *githubRepository) Config() *provisioning.Repository { @@ -47,12 +62,24 @@ func (r *githubRepository) Validate() (list field.ErrorList) { 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 &errors.StatusError{ + return &apierrors.StatusError{ ErrStatus: metav1.Status{ Message: "test is not yet implemented", Code: http.StatusNotImplemented, @@ -60,58 +87,252 @@ func (r *githubRepository) Test(ctx context.Context) error { } } -// TODO: add validation in admission hook -// extractOwnerAndRepo takes a GitHub repository URL and returns the owner and repo name. -func extractOwnerAndRepo(repoURL string) (string, string, error) { - parsedURL, err := url.Parse(repoURL) - if err != nil { - return "", "", fmt.Errorf("invalid URL: %w", err) - } - - // Split the path to get owner and repo - parts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") - if len(parts) < 2 { - return "", "", fmt.Errorf("URL does not contain owner and repo") - } - - owner := parts[0] - repo := parts[1] - return owner, repo, nil -} - // ReadResource implements provisioning.Repository. func (r *githubRepository) Read(ctx context.Context, filePath string, commit string) ([]byte, error) { - tokenSrc := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: r.config.Spec.GitHub.Token}, - ) - tokenClient := oauth2.NewClient(ctx, tokenSrc) - githubClient := github.NewClient(tokenClient) - - owner, repoName, err := extractOwnerAndRepo(r.config.Spec.GitHub.Repository) - if err != nil { - return nil, fmt.Errorf("failed to extract owner and repo: %w", err) - } - - content, _, _, err := githubClient.Repositories.GetContents(ctx, owner, repoName, filePath, &github.RepositoryContentGetOptions{ + content, _, _, err := r.githubClient.Repositories.GetContents(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, filePath, &github.RepositoryContentGetOptions{ Ref: commit, }) if err != nil { - // TODO: return bad request status code - return nil, fmt.Errorf("failed to get content: %w", err) + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil, &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "file not found", + Code: http.StatusNotFound, + }, + } + } + + return nil, fmt.Errorf("get contents: %w", err) } data, err := content.GetContent() if err != nil { - return nil, err + return nil, fmt.Errorf("get content: %w", err) } return []byte(data), nil } // Webhook implements provisioning.Repository. -func (r *githubRepository) Webhook() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // really any handler - // get the repository from the path - _, _ = w.Write([]byte("TODO... handle github webhook " + r.URL.Path)) +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 + } + + 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, + }) + default: + responder.Error(apierrors.NewBadRequest("unsupported event type")) + } } } + +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") + } + + branch := r.config.Spec.GitHub.Branch + if branch == "" { + // Default to main if no branch is specified + // TODO: should this be a mutation hook instead? + branch = "main" + } + + // 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", branch) { + 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 { + resource, 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", resource, "commit", commit.GetID()) + } + + for _, file := range commit.Modified { + resource, 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", resource, "commit", commit.GetID()) + } + + for _, file := range commit.Removed { + resource, 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", resource, "commit", commit.GetID()) + } + + beforeRef = commit.GetID() + } + + return nil +} + +func (r *githubRepository) createWebhook(ctx context.Context) error { + hook := &github.Hook{ + Config: &github.HookConfig{ + URL: github.String(r.config.Spec.GitHub.WebhookURL), + ContentType: github.String("json"), + Secret: github.String(r.config.Spec.GitHub.WebhookSecret), + }, + Events: []string{"push"}, + Active: github.Bool(true), + } + + if _, _, err := r.githubClient.Repositories.CreateHook(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, hook); err != nil { + return err + } + + r.logger.Info("webhook created", "url", r.config.Spec.GitHub.WebhookURL) + + 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.githubClient.Repositories.ListHooks(ctx, owner, repoName, nil) + 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.Config.URL == oldCfg.WebhookURL { + hook.Config.Secret = github.String(newCfg.WebhookSecret) + _, _, err := r.githubClient.Repositories.EditHook(ctx, owner, repoName, *hook.ID, 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.Config.Secret = github.String(oldCfg.WebhookSecret) + if _, _, err := r.githubClient.Repositories.EditHook(ctx, owner, repoName, *hook.ID, 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.githubClient.Repositories.ListHooks(ctx, owner, name, nil) + if err != nil { + return fmt.Errorf("list existing webhooks: %w", err) + } + + for _, hook := range hooks { + if *hook.Config.URL == r.config.Spec.GitHub.WebhookURL { + if _, err := r.githubClient.Repositories.DeleteHook(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) +} diff --git a/pkg/registry/apis/provisioning/local.go b/pkg/registry/apis/provisioning/local.go index 7af61064ada..def2473e5f9 100644 --- a/pkg/registry/apis/provisioning/local.go +++ b/pkg/registry/apis/provisioning/local.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" v1 "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" ) @@ -131,7 +132,19 @@ func (r *localRepository) Read(ctx context.Context, path string, commit string) } // Webhook implements provisioning.Repository. -func (r *localRepository) Webhook() http.HandlerFunc { +func (r *localRepository) Webhook(responder rest.Responder) http.HandlerFunc { // webhooks are not supported with local return nil } + +func (r *localRepository) AfterCreate(ctx context.Context) error { + return nil +} + +func (r *localRepository) BeginUpdate(ctx context.Context, old Repository) (UndoFunc, error) { + return nil, nil +} + +func (r *localRepository) AfterDelete(ctx context.Context) error { + return nil +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 86695dc21d0..d89e1e32c1a 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -3,6 +3,7 @@ package provisioning import ( "context" "fmt" + "log/slog" "path/filepath" "slices" @@ -20,6 +21,9 @@ import ( "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/registry/generic/registry" + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -36,11 +40,13 @@ var ( type ProvisioningAPIBuilder struct { getter rest.Getter localFileResolver *LocalFolderResolver + logger *slog.Logger } func NewProvisioningAPIBuilder(local *LocalFolderResolver) *ProvisioningAPIBuilder { return &ProvisioningAPIBuilder{ localFileResolver: local, + logger: slog.Default().With("logger", "provisioning-api-builder"), } } @@ -99,6 +105,12 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv if err != nil { return fmt.Errorf("failed to create repository storage: %w", err) } + + repositoryStorage.AfterCreate = b.afterCreate + // AfterUpdate doesn't have the old object, so we have to use BeginUpdate + repositoryStorage.BeginUpdate = b.beginUpdate + repositoryStorage.AfterDelete = b.afterDelete + repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage) b.getter = repositoryStorage @@ -127,10 +139,10 @@ func (b *ProvisioningAPIBuilder) GetRepository(ctx context.Context, name string) if err != nil { return nil, err } - return b.asRepository(obj) + return b.asRepository(ctx, obj) } -func (b *ProvisioningAPIBuilder) asRepository(obj runtime.Object) (Repository, error) { +func (b *ProvisioningAPIBuilder) asRepository(ctx context.Context, obj runtime.Object) (Repository, error) { if obj == nil { return nil, fmt.Errorf("missing repository object") } @@ -143,12 +155,86 @@ func (b *ProvisioningAPIBuilder) asRepository(obj runtime.Object) (Repository, e case provisioning.LocalRepositoryType: return newLocalRepository(r, b.localFileResolver), nil case provisioning.GithubRepositoryType: - return newGithubRepository(r), nil + return newGithubRepository(ctx, r), nil case provisioning.S3RepositoryType: return newS3Repository(r), nil default: + return &unknownRepository{config: r}, nil + } +} + +func (b *ProvisioningAPIBuilder) afterCreate(obj runtime.Object, opts *metav1.CreateOptions) { + cfg, ok := obj.(*provisioning.Repository) + if !ok { + b.logger.Error("object is not *provisioning.Repository") + return + } + + ctx := context.Background() + repo, err := b.asRepository(ctx, cfg) + if err != nil { + b.logger.Error("failed to get repository", "error", err) + return + } + + if err := repo.AfterCreate(ctx); err != nil { + b.logger.Error("failed to run after create", "error", err) + return + } +} + +func (b *ProvisioningAPIBuilder) beginUpdate(ctx context.Context, obj, old runtime.Object, opts *metav1.UpdateOptions) (registry.FinishFunc, error) { + objCfg, ok := obj.(*provisioning.Repository) + if !ok { + return nil, fmt.Errorf("new object is not *provisioning.Repository") + } + oldCfg, ok := old.(*provisioning.Repository) + if !ok { + return nil, fmt.Errorf("old object is not *provisioning.Repository") + } + + repo, err := b.asRepository(ctx, objCfg) + if err != nil { + return nil, fmt.Errorf("get new repository: %w", err) + } + + oldRepo, err := b.asRepository(ctx, oldCfg) + if err != nil { + return nil, fmt.Errorf("get old repository: %w", err) + } + + undo, err := repo.BeginUpdate(ctx, oldRepo) + if err != nil { + return nil, err + } + + return func(ctx context.Context, success bool) { + if !success && undo != nil { + if err := undo(ctx); err != nil { + b.logger.Error("failed to undo failed update", "error", err) + } + } + }, nil +} + +func (b *ProvisioningAPIBuilder) afterDelete(obj runtime.Object, opts *metav1.DeleteOptions) { + ctx := context.Background() + cfg, ok := obj.(*provisioning.Repository) + if !ok { + b.logger.Error("object is not *provisioning.Repository") + return + } + + repo, err := b.asRepository(ctx, cfg) + if err != nil { + b.logger.Error("failed to get repository", "error", err) + return + } + + if err := repo.AfterDelete(ctx); err != nil { + b.logger.Error("failed to run after delete", "error", err) + return } - return &unknownRepository{config: r}, nil } func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { @@ -157,16 +243,27 @@ func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attri return nil // This is normal for sub-resource } - repo, err := b.asRepository(obj) + repo, err := b.asRepository(ctx, obj) if err != nil { return err } // Typed validation list := repo.Validate() - cfg := repo.Config() + if a.GetOperation() == admission.Update { + oldRepo, err := b.asRepository(ctx, a.GetOldObject()) + if err != nil { + return fmt.Errorf("get old repository for update: %w", err) + } + + if cfg.Spec.Type != oldRepo.Config().Spec.Type { + list = append(list, field.Invalid(field.NewPath("spec", "type"), + cfg.Spec.Type, "Changing repository type is not supported")) + } + } + if cfg.Spec.Title == "" { list = append(list, field.Required(field.NewPath("spec", "title"), "a repository title must be given")) } diff --git a/pkg/registry/apis/provisioning/s3.go b/pkg/registry/apis/provisioning/s3.go index 9e4d1a4da19..10b700d81e8 100644 --- a/pkg/registry/apis/provisioning/s3.go +++ b/pkg/registry/apis/provisioning/s3.go @@ -7,6 +7,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" v1 "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" ) @@ -60,7 +61,19 @@ func (r *s3Repository) Read(ctx context.Context, path string, commit string) ([] } // Webhook implements provisioning.Repository. -func (r *s3Repository) Webhook() http.HandlerFunc { +func (r *s3Repository) Webhook(responder rest.Responder) http.HandlerFunc { // webhooks are not supported with local return nil } + +func (r *s3Repository) AfterCreate(ctx context.Context) error { + return nil +} + +func (r *s3Repository) BeginUpdate(ctx context.Context, old Repository) (UndoFunc, error) { + return func(ctx context.Context) error { return nil }, nil +} + +func (r *s3Repository) AfterDelete(ctx context.Context) error { + return nil +} diff --git a/pkg/registry/apis/provisioning/types.go b/pkg/registry/apis/provisioning/types.go index 97bf13162ae..93b0648a485 100644 --- a/pkg/registry/apis/provisioning/types.go +++ b/pkg/registry/apis/provisioning/types.go @@ -7,6 +7,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" v1 "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" ) @@ -15,6 +16,19 @@ type RepoGetter interface { GetRepository(ctx context.Context, name string) (Repository, error) } +// UndoFunc is a function that can be called to undo a previous operation +type UndoFunc func(context.Context) error + +// Chain is a helper function to chain UndoFuncs together +func (f UndoFunc) Chain(ctx context.Context, next UndoFunc) UndoFunc { + return func(ctx context.Context) error { + if err := f(ctx); err != nil { + return err + } + return next(ctx) + } +} + type Repository interface { // The saved object Config() *provisioning.Repository @@ -31,7 +45,12 @@ type Repository interface { Read(ctx context.Context, path string, commit string) ([]byte, error) // For repositories that support webhooks - Webhook() http.HandlerFunc + Webhook(responder rest.Responder) http.HandlerFunc + + // Hooks called after the repository has been created, updated or deleted + AfterCreate(ctx context.Context) error + BeginUpdate(ctx context.Context, old Repository) (UndoFunc, error) + AfterDelete(ctx context.Context) error } var _ Repository = (*unknownRepository)(nil) @@ -77,7 +96,19 @@ func (r *unknownRepository) Read(ctx context.Context, path string, commit string } // Webhook implements provisioning.Repository. -func (r *unknownRepository) Webhook() http.HandlerFunc { +func (r *unknownRepository) Webhook(responder rest.Responder) http.HandlerFunc { // webhooks are not supported with local return nil } + +func (r *unknownRepository) AfterCreate(ctx context.Context) error { + return nil +} + +func (r *unknownRepository) BeginUpdate(ctx context.Context, old Repository) (UndoFunc, error) { + return nil, nil +} + +func (r *unknownRepository) AfterDelete(ctx context.Context) error { + return nil +} diff --git a/pkg/registry/apis/provisioning/webhook.go b/pkg/registry/apis/provisioning/webhook.go index 4ef34c8b654..bd8c0d123c2 100644 --- a/pkg/registry/apis/provisioning/webhook.go +++ b/pkg/registry/apis/provisioning/webhook.go @@ -57,7 +57,7 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim if err != nil { return nil, err } - webhook := repo.Webhook() + webhook := repo.Webhook(responder) if webhook == nil { return nil, &errors.StatusError{ ErrStatus: v1.Status{ diff --git a/pkg/services/apiserver/builder/admission.go b/pkg/services/apiserver/builder/admission.go index dbd39130572..04985d75332 100644 --- a/pkg/services/apiserver/builder/admission.go +++ b/pkg/services/apiserver/builder/admission.go @@ -26,6 +26,7 @@ func (b *builderAdmission) Validate(ctx context.Context, a admission.Attributes, if v, ok := b.validators[a.GetResource().GroupVersion()]; ok { return v.Validate(ctx, a, o) } + return nil } diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 6c40141aefb..21ed6ecb3ae 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -155,11 +155,13 @@ func TestIntegrationProvisioning(t *testing.T) { "github-example": { "description": "load resources from github", "github": { + "branch": "dummy-branch", "branchWorkflow": true, "generateDashboardPreviews": true, "owner": "grafana", "repository": "git-ui-sync-demo", "token": "github_pat_dummy", + "webhookURL": "https://dummyWebhookUrl/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example/webhook", "webhookSecret": "dummyWebhookSecret" }, "title": "Github Example", diff --git a/pkg/tests/apis/provisioning/testdata/github-example.yaml b/pkg/tests/apis/provisioning/testdata/github-example.yaml index 93d6308f0f3..3004ceaac8a 100644 --- a/pkg/tests/apis/provisioning/testdata/github-example.yaml +++ b/pkg/tests/apis/provisioning/testdata/github-example.yaml @@ -9,7 +9,9 @@ spec: github: owner: grafana repository: git-ui-sync-demo + branch: dummy-branch branchWorkflow: true generateDashboardPreviews: true token: "github_pat_dummy" + webhookUrl: "https://dummyWebhookUrl/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example/webhook" webhookSecret: "dummyWebhookSecret" \ No newline at end of file