[Prototyping] automatically create github webhook in provisioning (#96645)
This commit is contained in:
1 parent
7a2e9fc0c0
commit
76e494209f
11 files changed
+458
-56
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user