From be3c522f14fe49df26a67c8e5ba52913b5223c34 Mon Sep 17 00:00:00 2001 From: ston1th Date: Wed, 4 Dec 2019 14:26:39 +0100 Subject: [PATCH] first commit --- .gitignore | 2 + .ko.yaml | 1 + Dockerfile | 12 + LICENSE | 24 ++ README.md | 51 ++++ build.sh | 8 + config/100-namespace.yaml | 5 + config/200-podsecuritypolicy.yaml | 28 ++ config/300-rbac.yaml | 55 ++++ config/deployment.yaml | 27 ++ main.go | 422 ++++++++++++++++++++++++++++++ 11 files changed, 635 insertions(+) create mode 100644 .gitignore create mode 100644 .ko.yaml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build.sh create mode 100644 config/100-namespace.yaml create mode 100644 config/200-podsecuritypolicy.yaml create mode 100644 config/300-rbac.yaml create mode 100644 config/deployment.yaml create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b52c626 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +env.txt +secret.yaml diff --git a/.ko.yaml b/.ko.yaml new file mode 100644 index 0000000..6ccd2d5 --- /dev/null +++ b/.ko.yaml @@ -0,0 +1 @@ +defaultBaseImage: gcr.io/distroless/static:nonroot diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1e5422 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:alpine as builder +RUN adduser -D -g '' appuser +RUN mkdir /build +ADD . /build/ +WORKDIR /build +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -gcflags 'all=-e' -ldflags "-s -w" -o main . +FROM scratch +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /build/main /main +USER appuser +EXPOSE 8080 +ENTRYPOINT ["/main"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45785b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (C) 2019 Marius Schellenberger +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * The names of the authors and/or contributors may not be used to + endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ston1th BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1588156 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# status-reporter + +The status reporter is a controller that watches pipelineruns for status cahnges. +These are synced to the associated git commit hashes. + +For successful runs a preview environment comment will be added to the pull request. + +`PipelineRun` requirements: + +Params: + +* gitapi - `github`, `ghe` or `gitea` +* issue - pull request issue id +* scheme - `http://` or `https://` +* tektonurl - `http://tekton-dashboard.example.com` +* previewurl - url to your deployed preview environment + +Resources: + +* git - revision: githash, url: clone_url + +# Requirements + +* Kubernetes Cluster +* go +* ko + +# Build and run + +Create git authentication secret: + +**NOTE:** the name must be `git-auth`. + +``` +cat <secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: git-auth + namespace: status-reporter +type: Opaque +stringData: + github.com: ":" +EOF +``` + +``` +echo KO_DOCKER_REPO='gcr.io/my-gcloud-project-name' >env.txt + +./build.sh +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6accb9c --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +source env.txt +kubectl -n status-reporter delete deploy status-reporter +GO111MODULE=off ko apply -f config/ +kubectl apply -f secret.yaml +sleep 10 +kubectl -n status-reporter logs -f $(kubectl -n status-reporter get pod -ojsonpath='{.items[0].metadata.name}') diff --git a/config/100-namespace.yaml b/config/100-namespace.yaml new file mode 100644 index 0000000..2581aa3 --- /dev/null +++ b/config/100-namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: status-reporter diff --git a/config/200-podsecuritypolicy.yaml b/config/200-podsecuritypolicy.yaml new file mode 100644 index 0000000..baf2d31 --- /dev/null +++ b/config/200-podsecuritypolicy.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: policy/v1beta1 +kind: PodSecurityPolicy +metadata: + name: status-reporter +spec: + privileged: false + allowPrivilegeEscalation: false + requiredDropCapabilities: + - ALL + readOnlyRootFilesystem: true + hostNetwork: false + hostIPC: false + hostPID: false + runAsUser: + rule: 'RunAsAny' + seLinux: + rule: 'RunAsAny' + supplementalGroups: + rule: 'MustRunAs' + ranges: + - min: 1 + max: 65535 + fsGroup: + rule: 'MustRunAs' + ranges: + - min: 1 + max: 65535 diff --git a/config/300-rbac.yaml b/config/300-rbac.yaml new file mode 100644 index 0000000..d70448a --- /dev/null +++ b/config/300-rbac.yaml @@ -0,0 +1,55 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: status-reporter + namespace: status-reporter +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: status-reporter +rules: +- apiGroups: ["tekton.dev"] + resources: ["pipelineruns"] + verbs: ["get", "list", "watch"] +- apiGroups: ["policy"] + resources: ["podsecuritypolicies"] + resourceNames: ["status-reporter"] + verbs: ["use"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: status-reporter +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: status-reporter +subjects: +- kind: ServiceAccount + name: status-reporter + namespace: status-reporter +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: status-reporter + namespace: status-reporter +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: status-reporter + namespace: status-reporter +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: status-reporter +subjects: +- kind: ServiceAccount + name: status-reporter diff --git a/config/deployment.yaml b/config/deployment.yaml new file mode 100644 index 0000000..8e8903f --- /dev/null +++ b/config/deployment.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: status-reporter + namespace: status-reporter + labels: + app: status-reporter +spec: + replicas: 1 + selector: + matchLabels: + app: status-reporter + template: + metadata: + labels: + app: status-reporter + spec: + serviceAccountName: status-reporter + containers: + - name: status-reporter + image: git.giftfish.de/ston1th/status-reporter + resources: + requests: + cpu: 100m + memory: 128Mi + imagePullPolicy: Always diff --git a/main.go b/main.go new file mode 100644 index 0000000..a3bedb2 --- /dev/null +++ b/main.go @@ -0,0 +1,422 @@ +// Copyright (C) 2019 Marius Schellenberger + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + pipelinev1alpha1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" + "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" + extinf "github.com/tektoncd/pipeline/pkg/client/informers/externalversions" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + //"k8s.io/apimachinery/pkg/types" + //"k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +type PipelineRun = pipelinev1alpha1.PipelineRun + +func init() { + log.SetFlags(log.Flags() | log.Lshortfile) +} + +func getNS() (string, error) { + if ns := os.Getenv("POD_NAMESPACE"); ns != "" { + return ns, nil + } + if data, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { + if ns := strings.TrimSpace(string(data)); len(ns) > 0 { + return ns, nil + } + } + + return "default", nil +} + +func mapGitAPI(s string) string { + switch s { + case "ghe", "github": + return "api/v3" + case "gitea": + return "api/v1" + } + return "" +} + +func mapState(s string) string { + switch s { + case "Running": + return "pending" + case "PipelineRunCancelled": + return "error" + case "Failed": + return "failure" + case "Succeeded": + return "success" + } + return "" +} + +type Status struct { + Type string + Date time.Time +} + +type StatusCache struct { + sync.Mutex + cache map[string]Status +} + +func NewStatusCache() *StatusCache { + return &StatusCache{cache: make(map[string]Status)} +} + +func (sc *StatusCache) Set(sha, status string, st, fin time.Time) bool { + sc.Lock() + defer sc.Unlock() + s, ok := sc.cache[sha] + if !ok { + log.Printf("adding status to cache: %s %s", sha, status) + if fin.After(st) { + sc.cache[sha] = Status{status, fin} + return true + } + sc.cache[sha] = Status{status, st} + return true + } + if fin.After(s.Date) { + log.Printf("updating status in cache: %s %s", sha, status) + sc.cache[sha] = Status{status, fin} + return true + } + if st.After(s.Date) { + log.Printf("updating status in cache: %s %s", sha, status) + sc.cache[sha] = Status{status, st} + return true + } + if fin.Equal(s.Date) && s.Type != status { + log.Printf("updating status in cache: %s %s", sha, status) + sc.cache[sha] = Status{status, fin} + return true + } + if st.Equal(s.Date) && s.Type != status { + log.Printf("updating status in cache: %s %s", sha, status) + sc.cache[sha] = Status{status, st} + return true + } + return false +} + +func (sc *StatusCache) Remove(sha string) { + log.Printf("removing status from cache: %s", sha) + sc.Lock() + delete(sc.cache, sha) + sc.Unlock() +} + +func getGit(pr *PipelineRun) (rev, url string) { + for _, v := range pr.Spec.Resources { + rs := v.ResourceSpec + if rs == nil { + continue + } + if rs.Type != "git" { + continue + } + for _, rp := range rs.Params { + if rp.Name == "revision" { + rev = rp.Value + } + if rp.Name == "url" { + url = rp.Value + } + if rev != "" && url != "" { + return + } + } + } + return +} + +func getParam(pr *PipelineRun, name string) string { + for _, p := range pr.Spec.Params { + if p.Name == name { + return p.Value.StringVal + } + } + return "" +} + +func statusUpdate(obj interface{}, s *StatusCache, sec typedcorev1.SecretInterface) { + pr, ok := obj.(*PipelineRun) + if !ok { + return + } + if len(pr.Status.Conditions) < 1 { + return + } + status := pr.Status.Conditions[0].Reason + var ( + start time.Time + fin time.Time + ) + st := pr.Status.StartTime + f := pr.Status.CompletionTime + if st != nil { + start = (*st).Time + } + if f != nil { + fin = (*f).Time + } + sha, url := getGit(pr) + if sha == "" { + return + } + if !s.Set(sha, status, start, fin) { + return + } + secret, err := sec.Get("git-auth", metav1.GetOptions{}) + if err != nil { + log.Printf("error getting secret for %s:%s: %v", sha, status, err) + s.Remove(sha) + return + } + err = postStatus(pr, url, sha, status, secret) + if err != nil { + log.Printf("error posting status for %s:%s: %v", sha, status, err) + s.Remove(sha) + return + } + + if status != "Succeeded" { + return + } + issue := getParam(pr, "issue") + if issue == "" || issue == "master" { + log.Printf("skipped comment on issue %s for %s/%s", issue, pr.Namespace, pr.Name) + return + } + err = postComment(pr, url, issue, secret) + if err != nil { + log.Printf("error posting comment for %s:%s: %v", sha, status, err) + return + } +} + +const ( + contentType = "Content-Type" + jsonType = "application/json" +) + +type GitStatus struct { + Context string `json:"context"` + Description string `json:"description"` + State string `json:"state"` + TargetURL string `json:"target_url"` +} + +func getSecret(sec *corev1.Secret, host string) (username, password string, err error) { + s, ok := sec.Data[host] + if !ok { + err = fmt.Errorf("no key found for host %s in secret %s/%s", host, sec.Namespace, sec.Name) + return + } + a := strings.SplitN(string(s), ":", 2) + if len(a) < 2 { + err = fmt.Errorf("missing credentials for host %s in secret %s/%s", host, sec.Namespace, sec.Name) + return + } + username = a[0] + password = a[1] + return +} + +func postStatus(pr *PipelineRun, gitUrl, sha, status string, sec *corev1.Secret) error { + u, err := url.Parse(gitUrl) + if err != nil { + return err + } + uri := strings.TrimPrefix(strings.TrimSuffix(u.RequestURI(), ".git"), "/") + t := getParam(pr, "gitapi") + api := mapGitAPI(t) + if api == "" { + return fmt.Errorf("error parsing gittype: %s", t) + } + u.Path = strings.Join([]string{api, "repos", + uri, "statuses", sha}, "/") + tektonurl := getParam(pr, "tektonurl") + if tektonurl == "" { + log.Printf("missing tektonurl parameter for %s/%s", pr.Namespace, pr.Name) + } + s := &GitStatus{ + Context: "tekton", + Description: pr.Name, + State: mapState(status), + TargetURL: tektonurl, + } + buf := new(bytes.Buffer) + err = json.NewEncoder(buf).Encode(s) + if err != nil { + return err + } + req, err := http.NewRequest("POST", u.String(), buf) + if err != nil { + return err + } + req.Header.Set(contentType, jsonType) + user, pass, err := getSecret(sec, u.Host) + if err != nil { + return err + } + req.SetBasicAuth(user, pass) + c := &http.Client{Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + }).DialContext, + }} + _, err = c.Do(req) + return err +} + +type Comment struct { + Body string `json:"body"` +} + +func postComment(pr *PipelineRun, gitUrl, issue string, sec *corev1.Secret) error { + u, err := url.Parse(gitUrl) + if err != nil { + return err + } + uri := strings.TrimPrefix(strings.TrimSuffix(u.RequestURI(), ".git"), "/") + t := getParam(pr, "gitapi") + api := mapGitAPI(t) + if api == "" { + return fmt.Errorf("error parsing gittype: %s", t) + } + u.Path = strings.Join([]string{api, "repos", + uri, "issues", issue, "comments"}, "/") + previewurl := getParam(pr, "previewurl") + if previewurl == "" { + return fmt.Errorf("missing previewurl parameter for %s/%s", pr.Namespace, pr.Name) + } + scheme := getParam(pr, "scheme") + preview := fmt.Sprintf("Preview Environment: [Link](%s%s)", scheme, previewurl) + user, pass, err := getSecret(sec, u.Host) + if err != nil { + return err + } + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return err + } + req.Header.Set(contentType, jsonType) + req.SetBasicAuth(user, pass) + c := &http.Client{Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + }).DialContext, + }} + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if bytes.Contains(body, []byte(preview)) { + return nil + } + + comment := &Comment{preview} + buf := new(bytes.Buffer) + err = json.NewEncoder(buf).Encode(comment) + if err != nil { + return err + } + req, err = http.NewRequest("POST", u.String(), buf) + if err != nil { + return err + } + req.Header.Set(contentType, jsonType) + req.SetBasicAuth(user, pass) + _, err = c.Do(req) + if err == nil { + log.Printf("set git comment: %s %s", u.String(), previewurl) + } + return err +} + +func main() { + cfg, err := rest.InClusterConfig() + if err != nil { + log.Fatalf("error configuring kube client: %s\n", err) + } + + cl, err := versioned.NewForConfig(cfg) + if err != nil { + log.Fatalf("error creating new versioned kube client: %s\n", err) + } + kcl, err := kubernetes.NewForConfig(cfg) + if err != nil { + log.Fatalf("error creating new kube client: %s\n", err) + } + ns, err := getNS() + if err != nil { + log.Fatalf("error getting current namespace %s\n", err) + } + sec := kcl.CoreV1().Secrets(ns) + + extFactory := extinf.NewSharedInformerFactory(cl, time.Second*30) + prInformer := extFactory.Tekton().V1alpha1().PipelineRuns().Informer() + + s := NewStatusCache() + + prInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + statusUpdate(obj, s, sec) + }, + UpdateFunc: func(_, obj interface{}) { + statusUpdate(obj, s, sec) + }, + DeleteFunc: func(obj interface{}) { + pr, ok := obj.(*PipelineRun) + if !ok { + return + } + sha, _ := getGit(pr) + if sha != "" { + s.Remove(sha) + } + }, + }) + + stop := make(chan struct{}) + extFactory.Start(stop) + log.Println("started pipelinerun watcher") + + sigs := make(chan os.Signal) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + log.Println("started signal handler") + sig := <-sigs + close(stop) + log.Printf("signal: %s\n", sig.String()) +}