diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..6f6cfd5 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,25 @@ +name: Go + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.15 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..871eb4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +debug.test +yosoy \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 32251c0..c9f0e8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ -FROM golang:1.13.5-alpine3.10 as builder +FROM golang:1.15.7-alpine3.13 as builder LABEL maintainer="Łukasz Budnik lukasz.budnik@gmail.com" # build yosoy -RUN apk add git +RUN apk --update add git RUN git clone https://github.com/lukaszbudnik/yosoy.git RUN cd /go/yosoy && go build -FROM alpine:3.10 +FROM alpine:3.13 COPY --from=builder /go/yosoy/yosoy /bin # register entrypoint diff --git a/README.md b/README.md index aa065c1..007b298 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,24 @@ Typical use cases include: * testing HTTP caching * stubbing and prototyping distributed applications -yosoy will provide information like: - -* Request URI -* Hostname -* Remote IP -* How many times it was called -* HTTP headers -* Env variables if `YOSOY_SHOW_ENVS` is set to `true`, `yes`, `on`, or `1` -* Files' contents if `YOSOY_SHOW_FILES` is set to a comma-separated list of (valid) files +## API + +yosoy responds to all requests with a JSON containing the information about: + +* HTTP request: + * Host + * Request URI + * Remote IP + * HTTP headers + * HTTP proxy headers +* host: + * Hostname + * How many times it was called + * Env variables if `YOSOY_SHOW_ENVS` is set to `true`, `yes`, `on`, or `1` + * Files' contents if `YOSOY_SHOW_FILES` is set to a comma-separated list of (valid) files See [Kubernetes example](#kubernetes-example) below. - ## Docker image The docker image is available on docker hub: @@ -36,116 +41,58 @@ It exposes HTTP service on port 80. ## Kubernetes example -Let's take a look at a sample Kubernetes deployment file. It uses both `YOSOY_SHOW_ENVS` and `YOSOY_SHOW_FILES`. - -> To illustrate `YOSOY_SHOW_FILES` functionality Kubernetes Downward API is used to expose labels and annotations as volume files which are then returned by yosoy. - -``` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: camarero - labels: - app.kubernetes.io/name: camarero -spec: - replicas: 2 - selector: - matchLabels: - app.kubernetes.io/name: camarero - template: - metadata: - labels: - app.kubernetes.io/name: camarero - spec: - containers: - - name: yosoy - image: lukasz/yosoy - env: - - name: YOSOY_SHOW_ENVS - value: "true" - - name: YOSOY_SHOW_FILES - value: "/etc/podinfo/labels,/etc/podinfo/annotations" - ports: - - containerPort: 80 - volumeMounts: - - name: podinfo - mountPath: /etc/podinfo - volumes: - - name: podinfo - downwardAPI: - items: - - path: "labels" - fieldRef: - fieldPath: metadata.labels - - path: "annotations" - fieldRef: - fieldPath: metadata.annotations ---- -apiVersion: v1 -kind: Service -metadata: - name: camarero - labels: - app.kubernetes.io/name: camarero -spec: - type: NodePort - selector: - app.kubernetes.io/name: camarero - ports: - - protocol: TCP - port: 80 -``` - -Deploy above service (with 2 replicas) and execute curl to the service a couple of times: - -``` -kubectl apply -f test-deployment.yaml -export NODE_PORT=$(kubectl get services/camarero -o go-template='{{(index .spec.ports 0).nodePort}}') -curl $(minikube ip):$NODE_PORT -curl $(minikube ip):$NODE_PORT -curl $(minikube ip):$NODE_PORT -curl $(minikube ip):$NODE_PORT +There is a sample Kubernetes deployment file in the `test` folder. It uses both `YOSOY_SHOW_ENVS` and `YOSOY_SHOW_FILES`. The deployment uses Kubernetes Downward API to expose labels and annotations as volume files which are then returned by yosoy. + +Deploy it to minikube and execute curl to the service a couple of times: + +```bash +# start minikube +minikube start +# deploy test service +kubectl apply -f test/deployment.yaml +# tunnel to it and copy the URL as $URL variable +minikube service --url camarero +# call it a few times +curl $URL +curl $URL +curl $URL +curl $URL ``` A sample response looks like this: -``` -Request URI: / -Hostname: camarero-859d7c6d6b-kb5s5 -Remote IP: 172.18.0.1 -Called: 2 - -HTTP headers: -Accept: */* -User-Agent: curl/7.64.1 - -Env variables: -PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -HOSTNAME=camarero-859d7c6d6b-kb5s5 -YOSOY_SHOW_ENVS=true -YOSOY_SHOW_FILES=/etc/podinfo/labels,/etc/podinfo/annotations -CAMARERO_PORT_80_TCP_PORT=80 -CAMARERO_PORT_80_TCP_ADDR=10.105.203.131 -KUBERNETES_PORT=tcp://10.96.0.1:443 -KUBERNETES_PORT_443_TCP_PORT=443 -CAMARERO_SERVICE_HOST=10.105.203.131 -KUBERNETES_PORT_443_TCP_PROTO=tcp -KUBERNETES_SERVICE_HOST=10.96.0.1 -KUBERNETES_SERVICE_PORT=443 -KUBERNETES_SERVICE_PORT_HTTPS=443 -KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 -KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 -CAMARERO_PORT=tcp://10.105.203.131:80 -CAMARERO_SERVICE_PORT=80 -CAMARERO_PORT_80_TCP=tcp://10.105.203.131:80 -CAMARERO_PORT_80_TCP_PROTO=tcp -HOME=/root - -File /etc/podinfo/labels: -app.kubernetes.io/name="camarero" -pod-template-hash="859d7c6d6b" - -File /etc/podinfo/annotations: -kubernetes.io/config.seen="2020-11-17T07:38:15.374049163Z" -kubernetes.io/config.source="api" +```json +{ + "host": "127.0.0.1:53366", + "requestUri": "/", + "remoteAddr": "172.17.0.1", + "counter": 4, + "headers": { + "Accept": [ + "*/*" + ], + "User-Agent": [ + "curl/7.64.1" + ] + }, + "hostname": "camarero-77787464ff-hjdkq", + "envVariables": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "HOSTNAME=camarero-77787464ff-hjdkq", + "YOSOY_SHOW_ENVS=true", + "YOSOY_SHOW_FILES=/etc/podinfo/labels,/etc/podinfo/annotations", + "CAMARERO_SERVICE_HOST=10.97.113.33", + "CAMARERO_PORT=tcp://10.97.113.33:80", + "CAMARERO_PORT_80_TCP=tcp://10.97.113.33:80", + "CAMARERO_PORT_80_TCP_ADDR=10.97.113.33", + "CAMARERO_SERVICE_PORT=80", + "CAMARERO_PORT_80_TCP_PROTO=tcp", + "CAMARERO_PORT_80_TCP_PORT=80", + "HOME=/root" + ], + "files": { + "/etc/podinfo/annotations": "kubernetes.io/config.seen=\"2021-02-03T13:18:34.563751030Z\"\nkubernetes.io/config.source=\"api\"", + "/etc/podinfo/labels": "app.kubernetes.io/name=\"camarero\"\npod-template-hash=\"77787464ff\"" + } +} ``` diff --git a/TestDockerfile b/TestDockerfile new file mode 100644 index 0000000..c2c8a93 --- /dev/null +++ b/TestDockerfile @@ -0,0 +1,15 @@ +FROM golang:1.15.7-alpine3.13 as builder + +LABEL maintainer="Łukasz Budnik lukasz.budnik@gmail.com" + +# build yosoy +ADD . /go/yosoy +RUN cd /go/yosoy && go build + +FROM alpine:3.13 +COPY --from=builder /go/yosoy/yosoy /bin + +# register entrypoint +ENTRYPOINT ["yosoy"] + +EXPOSE 80 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7bb8178 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/lukaszbudnik/yosoy + +go 1.15 + +require ( + github.com/gorilla/handlers v1.5.1 + github.com/gorilla/mux v1.8.0 + github.com/stretchr/testify v1.7.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60bf2d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server.go b/server.go index eb706e6..5be35c2 100644 --- a/server.go +++ b/server.go @@ -1,76 +1,90 @@ package main import ( - "fmt" + "encoding/json" "io/ioutil" + "log" "net/http" "os" - "sort" "strings" - "time" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" ) +type response struct { + Host string `json:"host"` + RequestURI string `json:"requestUri"` + RemoteAddr string `json:"remoteAddr"` + Counter int `json:"counter"` + Headers map[string][]string `json:"headers"` + Hostname string `json:"hostname"` + EnvVariables []string `json:"envVariables,omitempty"` + Files map[string]string `json:"files,omitempty"` +} + var counter = 0 var hostname = os.Getenv("HOSTNAME") -var showEnvs = os.Getenv("YOSOY_SHOW_ENVS") -var showFiles = os.Getenv("YOSOY_SHOW_FILES") func handler(w http.ResponseWriter, req *http.Request) { - if req.RequestURI == "/favicon.ico" { - w.WriteHeader(http.StatusNotFound) - return - } + showEnvs := os.Getenv("YOSOY_SHOW_ENVS") + showFiles := os.Getenv("YOSOY_SHOW_FILES") + + response := &response{} - remoteAddr := req.RemoteAddr - if index := strings.LastIndex(remoteAddr, ":"); index > 0 { - remoteAddr = remoteAddr[0:index] - } - fmt.Printf("[%v] - %v - %v - \"%v %v\"\n", hostname, time.Now().Format(time.RFC3339), remoteAddr, req.Method, req.RequestURI) - w.WriteHeader(http.StatusOK) - w.Header().Add("Content-Type", "text/plain") - fmt.Fprintf(w, "Request URI: %v\n", req.RequestURI) - fmt.Fprintf(w, "Hostname: %v\n", hostname) - fmt.Fprintf(w, "Remote IP: %v\n", remoteAddr) counter++ - fmt.Fprintf(w, "Called: %v\n", counter) - fmt.Fprintln(w) - fmt.Fprintf(w, "HTTP headers:\n") - headers := make([]string, 0, len(req.Header)) - for k := range req.Header { - headers = append(headers, k) - } - sort.Strings(headers) - for _, header := range headers { - headers := req.Header[header] - for _, h := range headers { - fmt.Fprintf(w, "%v: %v\n", header, h) - } - } + response.Counter = counter + + remoteAddr := remoteAddrWithoutPort(req) + response.RemoteAddr = remoteAddr + + response.RequestURI = req.RequestURI + response.Host = req.Host + response.Headers = req.Header + + response.Hostname = hostname + if strings.ToLower(showEnvs) == "true" || strings.ToLower(showEnvs) == "yes" || strings.ToLower(showEnvs) == "on" || showEnvs == "1" { - fmt.Fprintln(w) - fmt.Fprintf(w, "Env variables:\n") - for _, e := range os.Environ() { - fmt.Fprintln(w, e) - } + response.EnvVariables = os.Environ() } if len(showFiles) > 0 { + response.Files = make(map[string]string) files := strings.Split(showFiles, ",") for _, file := range files { bytes, err := ioutil.ReadFile(file) if err != nil { - fmt.Printf("[%v] - %v - could not read file %v: %v\n", hostname, time.Now().Format(time.RFC3339), file, err) + log.Printf("Could not read file %v: %v\n", file, err) continue } - fmt.Fprintln(w) - fmt.Fprintf(w, "File %v:\n", file) contents := string(bytes) - fmt.Fprintln(w, contents) + response.Files[file] = contents } } + + w.Header().Add("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +func remoteAddrWithoutPort(req *http.Request) string { + remoteAddr := req.RemoteAddr + if index := strings.LastIndex(remoteAddr, ":"); index > 0 { + remoteAddr = remoteAddr[0:index] + } + return remoteAddr } func main() { - fmt.Printf("[%v] - %v - yosoy is up!\n", hostname, time.Now().Format(time.RFC3339)) - http.HandleFunc("/", handler) - http.ListenAndServe(":80", nil) + log.Printf("yosoy is up %v\n", hostname) + + r := mux.NewRouter() + + r.Handle("/favicon.ico", r.NotFoundHandler) + r.PathPrefix("/").HandlerFunc(handler) + + loggingRouter := handlers.CombinedLoggingHandler(os.Stdout, r) + proxyRouter := handlers.ProxyHeaders(loggingRouter) + recoveryRouter := handlers.RecoveryHandler()(proxyRouter) + + http.ListenAndServe(":80", recoveryRouter) } diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..7bdefda --- /dev/null +++ b/server_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHandler(t *testing.T) { + os.Setenv("YOSOY_SHOW_ENVS", "true") + os.Setenv("YOSOY_SHOW_FILES", ".gitignore") + + req, err := http.NewRequest("GET", "https://example.org/sample/path", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "*/*") + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(handler) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + var response response + json.Unmarshal(rr.Body.Bytes(), &response) + + assert.Equal(t, 1, response.Counter) + assert.Equal(t, "example.org", response.Host) + assert.NotEmpty(t, response.EnvVariables) + assert.NotEmpty(t, response.Files[".gitignore"]) +} diff --git a/test/deployment.yaml b/test/deployment.yaml new file mode 100644 index 0000000..1b672e7 --- /dev/null +++ b/test/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: camarero + labels: + app.kubernetes.io/name: camarero +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: camarero + template: + metadata: + labels: + app.kubernetes.io/name: camarero + spec: + containers: + - name: yosoy + image: lukasz/yosoy + env: + - name: YOSOY_SHOW_ENVS + value: "true" + - name: YOSOY_SHOW_FILES + value: "/etc/podinfo/labels,/etc/podinfo/annotations" + ports: + - containerPort: 80 + volumeMounts: + - name: podinfo + mountPath: /etc/podinfo + volumes: + - name: podinfo + downwardAPI: + items: + - path: "labels" + fieldRef: + fieldPath: metadata.labels + - path: "annotations" + fieldRef: + fieldPath: metadata.annotations +--- +apiVersion: v1 +kind: Service +metadata: + name: camarero + labels: + app.kubernetes.io/name: camarero +spec: + type: NodePort + selector: + app.kubernetes.io/name: camarero + ports: + - protocol: TCP + port: 80