first commit

This commit is contained in:
ston1th 2019-12-04 14:26:39 +01:00
commit be3c522f14
11 changed files with 635 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
env.txt
secret.yaml

1
.ko.yaml Normal file
View file

@ -0,0 +1 @@
defaultBaseImage: gcr.io/distroless/static:nonroot

12
Dockerfile Normal file
View file

@ -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"]

24
LICENSE Normal file
View file

@ -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.

51
README.md Normal file
View file

@ -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 <<EOF>secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: git-auth
namespace: status-reporter
type: Opaque
stringData:
github.com: "<username>:<password>"
EOF
```
```
echo KO_DOCKER_REPO='gcr.io/my-gcloud-project-name' >env.txt
./build.sh
```

8
build.sh Executable file
View file

@ -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}')

View file

@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: status-reporter

View file

@ -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

55
config/300-rbac.yaml Normal file
View file

@ -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

27
config/deployment.yaml Normal file
View file

@ -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

422
main.go Normal file
View file

@ -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())
}