diff --git a/README.md b/README.md index ebc7a5b..765cb93 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,28 @@ # k8s-webhook-handler -The k8s-webhook-handler listens for (GitHub) webhooks and acts on various events - -## Event Handlers -### DeleteEvent -On branch deletion and deletes all resources in a kubernetes cluster that have a -label matching the repo name and are in a namespace matching the branch name. -If there are no other objects with the given label key in the namespace, it also -deletes the namespace and all remaining objects. - -### PushEvent -On push events, k8s-webhook-handler will checkout `.ci/workflow.yaml` from the -repo the push was and submit it to the k8s api with the following annotations -added: - - - `k8s-webhook-handler.io/ref`: event.Ref - - `k8s-webhook-handler.io/before`: event.Before - - `k8s-webhook-handler.io/revision`: event.HeadCommit.ID - - `k8s-webhook-handler.io/repo_name`: event.Repo.FullName - - `k8s-webhook-handler.io/repo_url`: event.Repo.GitURL - - `k8s-webhook-handler.io/repo_ssh`: event.Repo.SSHURL +Create Kubernetes resources in response to (GitHub) webhooks! + +## How does it work? +When the k8s-webhook-handler receives a webhook, it downloads a manifest +(`.ci/workflow.yaml` by default) from the repository. + +For push events, it downloads the manifest from the given revision. Otherwise +it's checked out from the repository's default branch. + +After that, it applies the manifest and adds the following annotations: + + - `k8s-webhook-handler.io/ref`: Git reference (e.g. `refs/heads/master`) + - `k8s-webhook-handler.io/revision`: Revision of HEAD + - `k8s-webhook-handler.io/repo_name`: Repo name including user + (e.g. `itskoko/k8s-webhook-handler`) + - `k8s-webhook-handler.io/repo_url`: git URL (e.g. + `git://github.com/itskoko/k8s-webhook-handler.git`) + - `k8s-webhook-handler.io/repo_ssh`: ssh URL (e.g. + `git@github.com:itskoko/k8s-webhook-handler.git`) ## Binaries - cmd/webhook is the actual webhook handling server -- cmd/reconciler iterates over all k8s namespaces and deletes all objects that - are labeled for which there is no remote branch anymore. ## Usage -Currently only github delete webhooks in json format are supported. Beside the manifests and templates in `deploy/`, a secret 'webhook-handler' with the following fields is expected: diff --git a/cmd/reconciler/main.go b/cmd/reconciler/main.go deleted file mode 100644 index 32c6a63..0000000 --- a/cmd/reconciler/main.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "flag" - "os" - - "github.com/go-kit/kit/log" - "github.com/go-kit/kit/log/level" - handler "github.com/itskoko/k8s-webhook-handler" -) - -var ( - sourceSelectorKey = flag.String("sk", "ci-source-repo", "Label key that identifies source repo") - kubeconfig = flag.String("kubeconfig", "", "If set, use this kubeconfig to connect to kubernetes") - dryRun = flag.Bool("dry", false, "Enable dry-run, print resources instead of deleting them") - gitAddress = flag.String("git", "git@github.com", "Git address") - - logger = log.With(log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)), "caller", log.Caller(5)) -) - -func main() { - flag.Parse() - kconfig, err := handler.NewKubernetesConfig(*kubeconfig) - if err != nil { - level.Error(logger).Log("msg", "Couldn't create kubernetes config", "err", err) - os.Exit(1) - } - dh, err := handler.NewDeleteHandler(logger, kconfig, *sourceSelectorKey, *dryRun) - dh.GitAddress = *gitAddress - if err != nil { - level.Error(logger).Log("msg", "Couldn't create handler", "err", err) - os.Exit(1) - } - if err := dh.PurgeBranchless(); err != nil { - level.Error(logger).Log("msg", "Couldn't delete branchless resources", "err", err) - os.Exit(1) - } -} diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index 53e4325..6c8192c 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -5,6 +5,7 @@ import ( "flag" "net/http" "os" + "regexp" "time" "github.com/go-kit/kit/log" @@ -15,36 +16,35 @@ import ( ) var ( - listenAddr = flag.String("l", ":8080", "Address to listen on for webhook requests") - sourceSelectorKey = flag.String("sk", "ci-source-repo", "Label key that identifies source repo") - namespace = flag.String("ns", "ci", "Namespace to deploy workflows to") - kubeconfig = flag.String("kubeconfig", "", "If set, use this kubeconfig to connect to kubernetes") - dryRun = flag.Bool("dry", false, "Enable dry-run, print resources instead of deleting them") - baseURL = flag.String("gh-base-url", "", "GitHub Enterprise: Base URL") - uploadURL = flag.String("gh-upload-url", "", "GitHub Enterprise: Upload URL") - gitAddress = flag.String("git", "git@github.com", "Git address") - debug = flag.Bool("debug", false, "Enable debug logging") - insecure = flag.Bool("insecure", false, "Allow omitting WEBHOOK_SECRET for testing") + listenAddr = flag.String("l", ":8080", "Address to listen on for webhook requests") + namespace = flag.String("ns", "ci", "Namespace to deploy workflows to") + resoucePath = flag.String("p", ".ci/workflow.yaml", "Path to resource manifest in repository") + kubeconfig = flag.String("kubeconfig", "", "If set, use this kubeconfig to connect to kubernetes") + baseURL = flag.String("gh-base-url", "", "GitHub Enterprise: Base URL") + uploadURL = flag.String("gh-upload-url", "", "GitHub Enterprise: Upload URL") + gitAddress = flag.String("git", "git@github.com", "Git address") + debug = flag.Bool("debug", false, "Enable debug logging") + dryRun = flag.Bool("dry", false, "Dry run; Do not apply resouce manifest") + insecure = flag.Bool("insecure", false, "Allow omitting WEBHOOK_SECRET for testing") + ignoreRef = flag.String("ignore", "", "Ignore refs matching this regex") statsdAddress = flag.String("statsd.address", "localhost:8125", "Address to send statsd metrics to") statsdProto = flag.String("statsd.proto", "udp", "Protocol to use for statsd") statsdInterval = flag.Duration("statsd.interval", 30*time.Second, "statsd flush interval") - - logger = log.With(log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)), "caller", log.Caller(5)) ) -func fatal(err error) { +func fatal(logger log.Logger, err error) { // FIXME: override caller, not add it - logger := log.With(logger, "caller", log.Caller(4)) - level.Error(logger).Log("msg", err.Error()) + level.Error(logger).Log("msg", err.Error(), "caller", log.Caller(4)) os.Exit(1) } func main() { + logger := log.With(log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)), "caller", log.Caller(5)) flag.Parse() githubSecret := os.Getenv("WEBHOOK_SECRET") if githubSecret == "" && !*insecure { - fatal(errors.New("WEBHOOK_SECRET not set. Use -insecure to disable webhook verification")) + fatal(logger, errors.New("WEBHOOK_SECRET not set. Use -insecure to disable webhook verification")) } if *debug { logger = level.NewFilter(logger, level.AllowAll()) @@ -52,38 +52,41 @@ func main() { logger = level.NewFilter(logger, level.AllowInfo()) } - kconfig, err := handler.NewKubernetesConfig(*kubeconfig) - if err != nil { - fatal(err) + config := &handler.Config{ + Namespace: *namespace, + ResourcePath: *resoucePath, + Secret: []byte(githubSecret), + DryRun: *dryRun, } - dh, err := handler.NewDeleteHandler(logger, kconfig, *sourceSelectorKey, *dryRun) - dh.GitAddress = *gitAddress - if err != nil { - fatal(err) + if *ignoreRef != "" { + level.Debug(logger).Log("msg", "Parsing regex", "regex", *ignoreRef) + regex, err := regexp.Compile(*ignoreRef) + if err != nil { + fatal(logger, err) + } + config.IgnoreRefRegex = regex } - ghClient, err := handler.NewGitHubClient(os.Getenv("GITHUB_TOKEN"), *baseURL, *uploadURL) + level.Info(logger).Log("msg", "Connecting to kubernetes", "kubeconfig", *kubeconfig) + kClient, err := handler.NewKubernetesClient(*kubeconfig) if err != nil { - fatal(err) + fatal(logger, err) } - ph, err := handler.NewPushHandler(logger, kconfig, ghClient) + loader, err := handler.NewGithubLoader(os.Getenv("GITHUB_TOKEN"), *baseURL, *uploadURL) if err != nil { - fatal(err) + fatal(logger, err) } - ph.Namespace = *namespace ticker := time.NewTicker(*statsdInterval) defer ticker.Stop() statsdClient := statsd.New("k8s-ci-purger.", logger) go statsdClient.SendLoop(ticker.C, *statsdProto, *statsdAddress) - h := handler.NewGithubHookHandler([]byte(githubSecret), statsdClient) - h.DeleteHandler = dh - h.PushHandler = ph + server := handler.NewGithubHookHandler(logger, config, kClient, loader, statsdClient) - http.Handle("/", h) + http.Handle("/", server) level.Info(logger).Log("msg", "Start listening", "addr", *listenAddr) - fatal(http.ListenAndServe(*listenAddr, nil)) + fatal(logger, http.ListenAndServe(*listenAddr, nil)) } diff --git a/cmd/webhook/main_test.go b/cmd/webhook/main_test.go deleted file mode 100644 index b118a29..0000000 --- a/cmd/webhook/main_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "testing" - - handler "github.com/itskoko/k8s-webhook-handler" - "k8s.io/apimachinery/pkg/labels" -) - -func TestSelector(t *testing.T) { - dh := &handler.DeleteHandler{ - SelectorKey: "ci-source-repo", - } - selector, err := dh.NewSelector("foo") - if err != nil { - t.Fatal(err) - } - for i, e := range []struct { - ls labels.Labels - shouldMatch bool - }{ - {labels.Set{"ci-source-repo": "foo"}, true}, - {labels.Set{"ci-source-repo": "bar"}, false}, - {labels.Set{}, false}, - } { - if selector.Matches(e.ls) != e.shouldMatch { - t.Fatalf("Test %d) Expected %v.Matches(%v) == %v", i, selector, e.ls, e.shouldMatch) - } - } -} diff --git a/delete.go b/delete.go deleted file mode 100644 index 0b5827f..0000000 --- a/delete.go +++ /dev/null @@ -1,258 +0,0 @@ -package handler - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/go-kit/kit/log" - "github.com/go-kit/kit/log/level" - "github.com/google/go-github/v24/github" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/selection" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - v1 "k8s.io/client-go/kubernetes/typed/core/v1" - "k8s.io/client-go/rest" - - "github.com/itskoko/k8s-webhook-handler/git" -) - -type DeleteHandler struct { - logger log.Logger - DryRun bool - discovery.DiscoveryInterface - dynamic.Interface - v1.NamespaceInterface - *kubernetes.Clientset - SelectorKey string - GitAddress string -} - -func NewDeleteHandler(logger log.Logger, kconfig *rest.Config, selectorKey string, dryRun bool) (*DeleteHandler, error) { - clientset, err := kubernetes.NewForConfig(kconfig) - if err != nil { - return nil, err - } - - intf, err := dynamic.NewForConfig(kconfig) - if err != nil { - return nil, err - } - - return &DeleteHandler{ - logger: logger, - DryRun: dryRun, - Interface: intf, - Clientset: clientset, - DiscoveryInterface: clientset.Discovery(), - NamespaceInterface: clientset.CoreV1().Namespaces(), - SelectorKey: selectorKey, - }, nil -} - -func (p *DeleteHandler) NewSelector(val string) (labels.Selector, error) { - req, err := labels.NewRequirement(p.SelectorKey, selection.Equals, []string{val}) - if err != nil { - // Should never happen - return nil, err - } - return labels.NewSelector().Add(*req), nil -} - -func (p *DeleteHandler) APIResources() ([]*metav1.APIResourceList, error) { - preferredResources, err := p.DiscoveryInterface.ServerPreferredResources() - if err != nil { - return nil, err - } - return discovery.FilteredBy( - discovery.ResourcePredicateFunc(func(groupVersion string, r *metav1.APIResource) bool { - return discovery.SupportsAllVerbs{Verbs: []string{"list", "create"}}.Match(groupVersion, r) - }), - preferredResources, - ), nil -} - -type resourceHandlerFn func(resource runtime.Unstructured, client dynamic.ResourceInterface) error - -func (p *DeleteHandler) HandleResources(namespace string, selector labels.Selector, handler resourceHandlerFn) ([]metav1.Object, error) { - resourceLists, err := p.APIResources() - if err != nil { - return nil, errors.WithStack(err) - } - - unhandled := []metav1.Object{} - for _, resourceList := range resourceLists { - gv, err := schema.ParseGroupVersion(resourceList.GroupVersion) - if err != nil { - return nil, errors.WithStack(err) - } - for _, resource := range resourceList.APIResources { - if !resource.Namespaced { - // Skip non-namespaced resources - continue - } - - uh, err := p.findAndHandleResource(&gv, &resource, selector, namespace, handler) - if err != nil { - return nil, errors.WithStack(err) - } - unhandled = append(unhandled, uh...) - } - } - return unhandled, nil -} - -func (p *DeleteHandler) PurgeBranchless() error { - req, err := labels.NewRequirement(p.SelectorKey, selection.Exists, nil) - if err != nil { - return err - } - selector := labels.NewSelector().Add(*req) - - namespaces, err := p.Clientset.CoreV1().Namespaces().List(metav1.ListOptions{}) - if err != nil { - return err - } - - level.Debug(logger).Log("msg", "Found namespaces", "selector", selector.String(), "namespaces", fmt.Sprintf("%v", namespaces.Items)) - for _, namespace := range namespaces.Items { - logger := log.With(logger, "namespace", namespace.ObjectMeta.Name, "selector", selector.String()) - if _, ok := namespace.GetAnnotations()[p.SelectorKey]; !ok { - level.Debug(logger).Log("msg", "namespace not tagged, skipping") - continue - } - namespaceInUse := false - _, err := p.HandleResources(namespace.ObjectMeta.Name, selector, func(resource runtime.Unstructured, client dynamic.ResourceInterface) error { - metadata, err := meta.Accessor(resource) - if err != nil { - return err - } - ls := labels.Set(metadata.GetLabels()) - name := metadata.GetName() - repo := fmt.Sprintf("%s:%s.git", p.GitAddress, labelValueToRepo(ls.Get(p.SelectorKey))) - logger := log.With(logger, "name", name, "self-link", metadata.GetSelfLink(), "repo", repo) - - exists, err := git.BranchExists(repo, namespace.ObjectMeta.Name) - if err != nil { - return errors.WithStack(err) - } - if exists { - level.Debug(logger).Log("msg", "branch still exists") - namespaceInUse = true - } - return nil - }) - if err != nil { - return err - } - if !namespaceInUse { - p.deleteNamespace(namespace.ObjectMeta.Name) - } - } - return nil -} - -func repoToLabelValue(repo string) string { - return strings.Replace(repo, "/", ".", -1) // label values may not contain / -} - -func labelValueToRepo(lv string) string { - return strings.Replace(lv, ".", "/", -1) -} - -func (p *DeleteHandler) Handle(_ context.Context, ev *github.DeleteEvent) (*handlerResponse, error) { - var ( - repo = *ev.Repo.FullName - branch = *ev.Ref - ) - if *ev.RefType != "branch" { - level.Info(p.logger).Log("msg", "Ignoring delete event for refType", "refType", *ev.RefType) - return &handlerResponse{http.StatusOK, "Nothing to do"}, nil - } - - // Map repo to label selector value and branch to namespace - selectorVal := repoToLabelValue(repo) - namespace := branch - selector, err := p.NewSelector(selectorVal) - if err != nil { - return nil, errors.WithStack(err) - } - - retained, err := p.HandleResources(namespace, selector, nil) - if err != nil { - return nil, errors.WithStack(err) - } - - resourcesLabeled := 0 - for _, metadata := range retained { - ls := labels.Set(metadata.GetLabels()) - if ls.Has(p.SelectorKey) { - resourcesLabeled++ - level.Debug(logger).Log("msg", "Found resource labeled for other repo", "repo", ls.Get(p.SelectorKey)) - } - } - if resourcesLabeled > 0 { - msg := "Found resources labeled for other repos, keeping namespace" - level.Info(logger).Log("msg", msg, "resourcesLabeled", resourcesLabeled) - return &handlerResponse{http.StatusOK, msg}, nil - } - if err := p.deleteNamespace(namespace); err != nil { - return &handlerResponse{http.StatusInternalServerError, "Couldn't delete namespace"}, err - } - return &handlerResponse{http.StatusOK, "Namespace deleted succesfully"}, nil -} - -func (p *DeleteHandler) deleteNamespace(namespace string) error { - level.Debug(logger).Log("msg", "Deleting empty namespace", "namespace", namespace) - if p.DryRun { - return nil - } - // Namespaces need to be deleted in the background. - propagationPolicy := metav1.DeletePropagationOrphan - return p.NamespaceInterface.Delete(namespace, &metav1.DeleteOptions{ - PropagationPolicy: &propagationPolicy, - }) -} - -func (p *DeleteHandler) findAndHandleResource(gv *schema.GroupVersion, resource *metav1.APIResource, selector labels.Selector, namespace string, handlerFn resourceHandlerFn) ([]metav1.Object, error) { - logger := log.With(logger, "namespace", namespace, "selector", selector) - level.Debug(logger).Log("msg", "Getting resources") - rclient := p.Interface.Resource(gv.WithResource(resource.Name)).Namespace(namespace) - list, err := rclient.List(metav1.ListOptions{}) - if err != nil { - return nil, fmt.Errorf("Couldn't List resources: %s", err) - } - resources, err := meta.ExtractList(list) - if err != nil { - return nil, fmt.Errorf("Couldn't extract list: %s", err) - } - unhandled := []metav1.Object{} - for _, resource := range resources { - unstructured, ok := resource.(runtime.Unstructured) - if !ok { - return nil, fmt.Errorf("Unexpected type for %v", resource) - } - metadata, err := meta.Accessor(unstructured) - if err != nil { - return nil, fmt.Errorf("Couldn't get metadata for %v: %s", unstructured, err) - } - if selector.Matches(labels.Set(metadata.GetLabels())) { - if handlerFn != nil { - if err := handlerFn(unstructured, rclient); err != nil { - return nil, fmt.Errorf("Handler failed: %s", err) - } - } - } else { - unhandled = append(unhandled, metadata) - } - } - return unhandled, nil -} diff --git a/event.go b/event.go new file mode 100644 index 0000000..3c6f615 --- /dev/null +++ b/event.go @@ -0,0 +1,81 @@ +package handler + +import ( + "errors" + + "github.com/google/go-github/v24/github" +) + +var ( + ErrEventNotSupported = errors.New("Event not supported") + ErrEventInvalid = errors.New("Not a valid event") +) + +type Event struct { + Type string + Action string + Revision string + Ref string + *github.Repository +} + +func (e *Event) Annotations() map[string]string { + return map[string]string{ + annotationPrefix + "event_type": e.Type, + annotationPrefix + "event_action": e.Action, + annotationPrefix + "repo_name": *e.Repository.FullName, + annotationPrefix + "repo_url": *e.Repository.GitURL, + annotationPrefix + "repo_ssh": *e.Repository.SSHURL, + annotationPrefix + "ref": e.Ref, + annotationPrefix + "revision": e.Revision, + } +} + +func ParseEvent(ev interface{}) (*Event, error) { + event := &Event{} + switch e := ev.(type) { + case *github.PushEvent: + event.Type = "push" + event.Repository = pushEventRepoToRepo(e.GetRepo()) + event.Revision = *e.After + event.Ref = *e.Ref + case *github.DeleteEvent: + event.Type = "delete" + event.Repository = e.GetRepo() + event.Ref = formatRef(*e.RefType, *e.Ref) + case *github.CheckRunEvent: + event.Type = "check_run" + event.Action = *e.Action + event.Repository = e.GetRepo() + event.Revision = *e.CheckRun.HeadSHA + event.Ref = branchToRef(*e.CheckRun.CheckSuite.HeadBranch) + case *github.CheckSuiteEvent: + event.Type = "check_suite" + event.Action = *e.Action + event.Repository = e.GetRepo() + event.Revision = *e.CheckSuite.AfterSHA + event.Ref = branchToRef(*e.CheckSuite.HeadBranch) + } + + return event, nil +} + +func formatRef(refType, ref string) string { + if refType == "branch" { + refType = "head" + } + return "refs/" + refType + "s/" + ref +} + +func branchToRef(branch string) string { + return "refs/heads/" + branch +} + +// FIXME: We should translate all fields or clean that mess up at upstream. +func pushEventRepoToRepo(r *github.PushEventRepository) *github.Repository { + return &github.Repository{ + FullName: r.FullName, + GitURL: r.GitURL, + SSHURL: r.SSHURL, + } +} diff --git a/github.go b/github.go deleted file mode 100644 index ffc0f79..0000000 --- a/github.go +++ /dev/null @@ -1,34 +0,0 @@ -package handler - -import ( - "context" - "net/url" - "os" - - "github.com/google/go-github/v24/github" - "golang.org/x/oauth2" -) - -func NewGitHubClient(token, baseURL, uploadURL string) (*github.Client, error) { - ctx := context.Background() - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, - ) - client := github.NewClient(oauth2.NewClient(ctx, ts)) - - if baseURL != "" { - bu, err := url.Parse(baseURL) - if err != nil { - return nil, err - } - client.BaseURL = bu - } - if uploadURL != "" { - uu, err := url.Parse(uploadURL) - if err != nil { - return nil, err - } - client.UploadURL = uu - } - return client, nil -} diff --git a/handler.go b/handler.go index a86bd35..6ed39c9 100644 --- a/handler.go +++ b/handler.go @@ -1,8 +1,10 @@ package handler import ( + "context" "fmt" "net/http" + "regexp" "time" "github.com/go-kit/kit/log" @@ -10,30 +12,45 @@ import ( "github.com/go-kit/kit/metrics" "github.com/go-kit/kit/metrics/statsd" "github.com/google/go-github/v24/github" + "k8s.io/apimachinery/pkg/api/meta" ) +const annotationPrefix = "k8s-webhook-handler.io/" + +type Config struct { + Namespace string + ResourcePath string + Secret []byte + IgnoreRefRegex *regexp.Regexp + DryRun bool +} + type Handler struct { - *DeleteHandler - *PushHandler - secret []byte + log.Logger + Config *Config + Loader + KubernetesClient requestCounter metrics.Counter errorCounter metrics.Counter callDuration metrics.Histogram } -func NewGithubHookHandler(secret []byte, statsdClient *statsd.Statsd) *Handler { +func NewGithubHookHandler(logger log.Logger, config *Config, kubernetesClient KubernetesClient, loader Loader, statsdClient *statsd.Statsd) *Handler { return &Handler{ - secret: secret, - requestCounter: statsdClient.NewCounter("requests", 1.0), - errorCounter: statsdClient.NewCounter("errors", 1.0), - callDuration: statsdClient.NewTiming("duration", 1.0), + Logger: logger, + Config: config, + Loader: loader, + KubernetesClient: kubernetesClient, + requestCounter: statsdClient.NewCounter("requests", 1.0), + errorCounter: statsdClient.NewCounter("errors", 1.0), + callDuration: statsdClient.NewTiming("duration", 1.0), } } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func(begin time.Time) { h.callDuration.Observe(time.Since(begin).Seconds()) }(time.Now()) - logger := log.With(logger, "client", r.RemoteAddr) + logger := log.With(h.Logger, "client", r.RemoteAddr) h.requestCounter.Add(1) hr, err := h.handle(w, r) if hr == nil { @@ -63,7 +80,7 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request) (*handlerRespon if r.Method != http.MethodPost { return &handlerResponse{http.StatusBadRequest, "Method not supported"}, nil } - payload, err := github.ValidatePayload(r, h.secret) + payload, err := github.ValidatePayload(r, h.Config.Secret) if err != nil { return &handlerResponse{http.StatusBadRequest, "Invalid payload"}, err } @@ -72,23 +89,40 @@ func (h *Handler) handle(w http.ResponseWriter, r *http.Request) (*handlerRespon if err != nil { return &handlerResponse{http.StatusBadRequest, "Couldn't parse webhook"}, err } + return h.HandleEvent(r.Context(), event) +} - errNotRegistered := fmt.Errorf("No handler for event %T registered", event) - switch e := event.(type) { - case *github.DeleteEvent: - if h.DeleteHandler == nil { - return &handlerResponse{http.StatusBadRequest, errNotRegistered.Error()}, errNotRegistered - } - return h.DeleteHandler.Handle(r.Context(), e) - case *github.PushEvent: - if h.PushHandler == nil { - return &handlerResponse{http.StatusBadRequest, errNotRegistered.Error()}, errNotRegistered - } - return h.PushHandler.Handle(r.Context(), e) +// Handler handles a webhook. +// We have to use interface{} because of https://github.com/google/go-github/issues/1154. +func (h *Handler) HandleEvent(ctx context.Context, ev interface{}) (*handlerResponse, error) { + event, err := ParseEvent(ev) + if err != nil { + return &handlerResponse{http.StatusBadRequest, "Invalid/unsupported event"}, err + } + logger := log.With(h.Logger, "revision", event.Revision, "ref", event.Ref) - default: - return &handlerResponse{http.StatusBadRequest, "Webhook not supported"}, nil + if h.Config.IgnoreRefRegex != nil && h.Config.IgnoreRefRegex.MatchString(event.Ref) { + level.Debug(logger).Log("msg", "Ref is ignored, skipping", "regex", h.Config.IgnoreRefRegex) + return &handlerResponse{message: "Ref is ignored, skipping"}, nil } + + obj, err := h.Loader.Load(ctx, *event.Repository.FullName, h.Config.ResourcePath, event.Revision) + if err != nil { + return &handlerResponse{message: "Couldn't downlaod manifest"}, err + } + + annotations := event.Annotations() + meta.NewAccessor().SetAnnotations(obj, annotations) + level.Info(logger).Log("msg", "Downloaded manifest succesfully") + if h.Config.DryRun { + level.Info(logger).Log("msg", "Dry run enabled, skipping apply", "obj", fmt.Sprintf("%s", obj)) + return nil, nil + } + if err := h.KubernetesClient.Apply(obj, h.Config.Namespace); err != nil { + return &handlerResponse{message: "Couldn't apply resource"}, err + } + + return nil, nil } type handlerResponse struct { diff --git a/handler_test.go b/handler_test.go index 2f5d1ba..615377b 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1,73 +1,65 @@ package handler import ( + "context" "fmt" "io/ioutil" "net/http/httptest" + "os" "testing" + "github.com/go-kit/kit/log" "github.com/go-kit/kit/metrics/statsd" - "github.com/google/go-github/v24/github" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - fake "k8s.io/client-go/kubernetes/fake" + "k8s.io/apimachinery/pkg/runtime" ) -func TestHandleDelete(t *testing.T) { - repo := "foo/bar" - selectorKey := "ci-source-repo" - selectorValue := "foo.bar" - branch := "master" - refType := "branch" +type mockKubernetesClient struct { + obj runtime.Object + namespace string +} - service := &v1.Service{ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: branch, - Labels: map[string]string{selectorKey: selectorValue}, - }} - namespace := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{ - Name: branch, - }} - clientset := fake.NewSimpleClientset(namespace, service) +func (k *mockKubernetesClient) Apply(obj runtime.Object, namespace string) error { + k.obj = obj + k.namespace = namespace + return nil +} - discoveryInterface := clientset.Discovery() - // FIXME: DiscoveryInterface mock isn't complete, so - // ServerPreferredResources() returns nothing and breaks the purger - t.Log(discoveryInterface.ServerPreferredResources()) - t.Log(clientset.Fake.Resources) - p := &DeleteHandler{ - DiscoveryInterface: discoveryInterface, - NamespaceInterface: clientset.CoreV1().Namespaces(), - SelectorKey: selectorKey, - } - h := NewGithubHookHandler([]byte("foo"), statsd.New("k8s-foobar.", logger)) - h.DeleteHandler = p +type mockLoader struct { + obj runtime.Object + repo string + path string + ref string +} - payload := github.DeleteEvent{ - RefType: &refType, - Ref: &branch, - Repo: &github.Repository{ - FullName: &repo, - }, - } - fmt.Println(payload) +func (l *mockLoader) Load(ctx context.Context, repo, path, ref string) (runtime.Object, error) { + return l.obj, nil +} - /* - pr, pw := io.Pipe() - enc := json.NewEncoder(pw) - go func() { enc.Encode(payload) }()*/ +func TestHandle(t *testing.T) { + var ( + config = &Config{Namespace: "namespace", ResourcePath: "foo/bar.yaml", Secret: []byte("foobar")} + ) - req := httptest.NewRequest("POST", "http://example.com/", nil) //pr) + logger := log.With(log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)), "caller", log.Caller(5)) + handler := NewGithubHookHandler( + logger, + config, + &mockKubernetesClient{}, + &mockLoader{}, + statsd.New("k8s-ci-purger.", logger), + ) + + req := httptest.NewRequest("POST", "http://example.com/", nil) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", "DeleteEvent") req.Header.Set("X-GitHub-Delivery", "4636fc67-b693-4a27-87a4-18d4021ae789") req.Header.Set("X-Hub-Signature", "sha1=1234") w := httptest.NewRecorder() - h.ServeHTTP(w, req) + + handler.ServeHTTP(w, req) resp := w.Result() body, _ := ioutil.ReadAll(resp.Body) - fmt.Println(resp.StatusCode) fmt.Println(resp.Header.Get("Content-Type")) fmt.Println(string(body)) diff --git a/kubernetes.go b/kubernetes.go index 4a30b19..7bf63cb 100644 --- a/kubernetes.go +++ b/kubernetes.go @@ -1,13 +1,74 @@ package handler import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" ) -func NewKubernetesConfig(kubeconfig string) (config *rest.Config, err error) { +type KubernetesClient interface { + Apply(obj runtime.Object, namespace string) error +} + +type kubernetesClient struct { + dynamic.Interface + meta.RESTMapper +} + +func NewKubernetesClient(kubeconfig string) (*kubernetesClient, error) { + config, err := buildKubernetesConfig(kubeconfig) + if err != nil { + return nil, err + } + + intf, err := dynamic.NewForConfig(config) + if err != nil { + return nil, err + } + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + + groupResources, err := restmapper.GetAPIGroupResources(clientset.Discovery()) + if err != nil { + return nil, err + } + return &kubernetesClient{ + Interface: intf, + RESTMapper: restmapper.NewDiscoveryRESTMapper(groupResources), + }, nil +} + +func buildKubernetesConfig(kubeconfig string) (config *rest.Config, err error) { if kubeconfig != "" { return clientcmd.BuildConfigFromFlags("", kubeconfig) } return rest.InClusterConfig() } + +func (k *kubernetesClient) Apply(obj runtime.Object, namespace string) error { + switch obj := obj.(type) { + case *unstructured.Unstructured: + gvk := obj.GroupVersionKind() + gk := schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind} + + mapping, err := k.RESTMapper.RESTMapping(gk, gvk.Version) + if err != nil { + return err + } + if _, err := k.Interface.Resource(mapping.Resource).Namespace(namespace).Create(obj, metav1.CreateOptions{}); err != nil { + return err + } + case *unstructured.UnstructuredList: + return obj.EachListItem(func(o runtime.Object) error { return k.Apply(o, namespace) }) + } + return nil +} diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..f8ae9c1 --- /dev/null +++ b/loader.go @@ -0,0 +1,85 @@ +package handler + +import ( + "context" + "fmt" + "io/ioutil" + "net/url" + "os" + "strings" + + "github.com/google/go-github/v24/github" + "golang.org/x/oauth2" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" +) + +type Loader interface { + Load(ctx context.Context, repo, path, ref string) (runtime.Object, error) +} + +type GithubLoader struct { + *github.Client +} + +func NewGithubLoader(token, baseURL, uploadURL string) (*GithubLoader, error) { + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, + ) + client := github.NewClient(oauth2.NewClient(ctx, ts)) + + if baseURL != "" { + bu, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + client.BaseURL = bu + } + if uploadURL != "" { + uu, err := url.Parse(uploadURL) + if err != nil { + return nil, err + } + client.UploadURL = uu + } + return &GithubLoader{client}, nil + +} + +// Apply downloads a manifest from repo specified by owner and name at given +// ref. Ref and path can be a SHA, branch, or tag. +func (l *GithubLoader) Load(ctx context.Context, repo, path, ref string) (runtime.Object, error) { + var ( + parts = strings.SplitN(repo, "/", 2) + owner = parts[0] + name = parts[1] + ) + var options *github.RepositoryContentGetOptions + if ref != "" { + options = &github.RepositoryContentGetOptions{ + Ref: ref, + } + } + + file, err := l.Client.Repositories.DownloadContents(ctx, owner, name, path, options) + if err != nil { + return nil, fmt.Errorf("Couldn't get file %s from %s/%s at %s: %s", path, owner, name, ref, err) + } + defer file.Close() + content, err := ioutil.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("Couldn't read file: %s", err) + } + + jcontent, err := yaml.ToJSON(content) + if err != nil { + return nil, fmt.Errorf("Couldn't translate yaml to json: %s", err) + } + obj, _, err := unstructured.UnstructuredJSONScheme.Decode(jcontent, nil, nil) + if err != nil { + return nil, fmt.Errorf("Couldn't decode manifest: %s", err) + } + return obj, nil +} diff --git a/logger.go b/logger.go deleted file mode 100644 index 4bba299..0000000 --- a/logger.go +++ /dev/null @@ -1,9 +0,0 @@ -package handler - -import ( - "os" - - "github.com/go-kit/kit/log" -) - -var logger = log.With(log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)), "caller", log.Caller(5)) diff --git a/push.go b/push.go deleted file mode 100644 index 88f19e9..0000000 --- a/push.go +++ /dev/null @@ -1,122 +0,0 @@ -package handler - -import ( - "context" - "fmt" - "io/ioutil" - - "github.com/go-kit/kit/log" - "github.com/go-kit/kit/log/level" - "github.com/google/go-github/v24/github" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" -) - -const annotationPrefix = "k8s-webhook-handler.io/" - -type PushHandler struct { - logger log.Logger - ghClient *github.Client - dynamic.Interface - *kubernetes.Clientset - secret []byte - ResourcePath string - Namespace string - meta.RESTMapper -} - -func NewPushHandler(logger log.Logger, kconfig *rest.Config, ghClient *github.Client) (*PushHandler, error) { - intf, err := dynamic.NewForConfig(kconfig) - if err != nil { - return nil, err - } - clientset, err := kubernetes.NewForConfig(kconfig) - if err != nil { - return nil, err - } - - groupResources, err := restmapper.GetAPIGroupResources(clientset.Discovery()) - if err != nil { - return nil, err - } - - return &PushHandler{ - logger: logger, - Interface: intf, - ghClient: ghClient, - RESTMapper: restmapper.NewDiscoveryRESTMapper(groupResources), - ResourcePath: ".ci/workflow.yaml", - }, nil -} - -func (h *PushHandler) Handle(ctx context.Context, event *github.PushEvent) (*handlerResponse, error) { - logger := log.With(h.logger, "repo", *event.Repo.Owner.Login+"/"+*event.Repo.Name) - file, err := h.ghClient.Repositories.DownloadContents( - ctx, - *event.Repo.Owner.Login, - *event.Repo.Name, - h.ResourcePath, - &github.RepositoryContentGetOptions{ - Ref: *event.After, - }) - if err != nil { - return nil, fmt.Errorf("Couldn't get file %s from %s/%s at %s: %s", h.ResourcePath, *event.Repo.Owner.Login, *event.Repo.Name, *event.HeadCommit.ID, err) - } - defer file.Close() - content, err := ioutil.ReadAll(file) - if err != nil { - return nil, fmt.Errorf("Couldn't read file: %s", err) - } - - jcontent, err := yaml.ToJSON(content) - if err != nil { - return nil, fmt.Errorf("Couldn't translate yaml to json: %s", err) - } - obj, _, err := unstructured.UnstructuredJSONScheme.Decode(jcontent, nil, nil) - if err != nil { - return nil, fmt.Errorf("Couldn't decode manifest: %s", err) - } - acr := meta.NewAccessor() - acr.SetAnnotations(obj, map[string]string{ - annotationPrefix + "ref": *event.Ref, - annotationPrefix + "before": *event.Before, - annotationPrefix + "revision": *event.HeadCommit.ID, - annotationPrefix + "repo_name": *event.Repo.FullName, - annotationPrefix + "repo_url": *event.Repo.GitURL, - annotationPrefix + "repo_ssh": *event.Repo.SSHURL, - }) - level.Info(logger).Log("msg", "Downloaded manifest succesfully", "obj", obj, "content", content) - - if err := h.apply(obj); err != nil { - return &handlerResponse{message: "Couldn't update resource"}, err - } - return nil, nil -} - -func (h *PushHandler) apply(obj runtime.Object) error { - switch obj := obj.(type) { - case *unstructured.Unstructured: - gvk := obj.GroupVersionKind() - gk := schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind} - - mapping, err := h.RESTMapper.RESTMapping(gk, gvk.Version) - if err != nil { - return err - } - if _, err := h.Interface.Resource(mapping.Resource).Namespace(h.Namespace).Create(obj, metav1.CreateOptions{}); err != nil { - return err - } - level.Debug(h.logger).Log("Updated object", "obj", fmt.Sprintf("%#v", obj)) - case *unstructured.UnstructuredList: - return obj.EachListItem(func(o runtime.Object) error { return h.apply(o) }) - } - return nil -}