Skip to content

Commit

Permalink
goss serve can now negotiate response's content-type via accept reque…
Browse files Browse the repository at this point in the history
…st header (#609)

* Find and fix a bug in structured output never returning a fail if tests fail

* Revert what I thought was a bugfix

* goss serve can now negotiate content via accept header

* docs

* Assert status OK

* unnecessary now

* also unnecessary

* extract funcs, reduce nesting -> clearer

* A log message saying what just happened is immeasurably useful

* Include output body into log when other than 200 OK, to aid debugging

* remove deliberate fail

* Fix behaviour, add more integration test coverage for obvious failure

* remove debugging logs, make curl on non-windows work
  • Loading branch information
Peter Mounce authored Oct 17, 2020
1 parent 88ba0bb commit e9518bf
Show file tree
Hide file tree
Showing 5 changed files with 352 additions and 54 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ Goss is a YAML based [serverspec](http://serverspec.org/) alternative tool for v

## Installation

**Note:** For macOS and Windows, see: [platform-feature-parity](https://github.com/aelsabbahy/goss/blob/master/docs/platform-feature-parity.md)

**Note:** For macOS and Windows, see: [platform-feature-parity].

This will install goss and [dgoss](https://github.com/aelsabbahy/goss/tree/master/extras/dgoss).

Expand Down Expand Up @@ -165,6 +164,10 @@ curl localhost:8080/healthz
# JSON endpoint
goss serve --format json &
curl localhost:8080/healthz
# rspecish response via content negotiation
goss serve --format json &
curl -H "Accept: application/vnd.goss-rspecish" localhost:8080/healthz
```

### Manually editing Goss files
Expand Down Expand Up @@ -258,7 +261,7 @@ package:

## Limitations

Currently goss only runs on Linux.
`goss` works well on Linux, but support on Windows & macOS is alpha. See [platform-feature-parity].

The following tests have limitations.

Expand All @@ -277,3 +280,4 @@ Service:
* Upstart

[kubernetes-simplified-health-checks]: https://medium.com/@aelsabbahy/docker-1-12-kubernetes-simplified-health-checks-and-container-ordering-with-goss-fa8debbe676c
[platform-feature-parity]: docs/platform-feature-parity.md
9 changes: 7 additions & 2 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,14 @@ service:
running: false
```


### serve, s - Serve a health endpoint

`serve` exposes the goss test suite as a health endpoint on your server. The end-point will return the stest results in the format requested and an http status of 200 or 503.

`serve` will look for a test suite in the same order as [validate](#validate-v---validate-the-system)

#### Flags

* `--cache <value>`, `-c <value>` - Time to cache the results (default: 5s)
* `--endpoint <value>`, `-e <value>` - Endpoint to expose (default: `/healthz`)
* `--format`, `-f` - output format, same as [validate](#validate-v---validate-the-system)
Expand All @@ -270,8 +270,13 @@ $ curl http://localhost:8080/healthz
# JSON endpoint
$ goss serve --format json &
$ curl localhost:8080/healthz
# rspecish output format in response via content negotiation
goss serve --format json &
curl -H "Accept: application/vnd.goss-rspecish" localhost:8080/healthz
```

The `application/vnd.goss-{output format}` media type can be used in the `Accept` request header to determine the response's content-type. You can also `Accept: application/json` to get back `application/json`.

### validate, v - Validate the system

Expand Down Expand Up @@ -898,7 +903,7 @@ For more information see:

## Templates

Goss test files can leverage golang's [text/template](https://golang.org/pkg/text/template/) to allow for dynamic or conditional tests.
Goss test files can leverage golang's [text/template](https://golang.org/pkg/text/template/) to allow for dynamic or conditional tests.

Available variables:
* `{{.Env}}` - Containing environment variables
Expand Down
39 changes: 33 additions & 6 deletions integration-tests/run-serve-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,37 @@ args=(
"--listen-addr=127.0.0.1:${open_port}"
)
log_action -e "\nTesting \`${GOSS_BINARY} ${args[*]}\` ...\n"

"${GOSS_BINARY}" "${args[@]}" &
if curl --silent "http://127.0.0.1:${open_port}/healthz" | grep 'Count: 2, Failed: 0, Skipped: 0' ; then
log_success "passed"
else
log_fatal "failed, exit code $?"
fi
url="http://127.0.0.1:${open_port}/healthz"

assert_response_contains() {
local url="${1:?"1st arg: url"}"
local test_name="${2:?"2nd arg: test name"}"
local expectation="${3:?"3rd arg: response body match"}"
local accept_header="${4:-""}"

curl_args=("--silent")
[[ -n "${accept_header:-}" ]] && curl_args+=("-H" "Accept: ${accept_header}")
curl_args+=("${url}")
log_info "curl ${curl_args[*]}"
curl="curl"
[[ "$(go env GOOS)" == "windows" ]] && curl="curl.exe"
response="$(${curl} "${curl_args[@]}")"
if grep --quiet "${expectation}" <<<"${response}"; then
log_success "Passed: ${test_name}"
return 0
fi
log_error "Failed: ${test_name}"
log_error " Expected: ${expectation}"
log_error " Response: ${response}"
return 1
}
failure="false"
on_test_failure() {
failure="true"
}
assert_response_contains "${url}" "no accept header" "Count: 2, Failed: 0, Skipped: 0" "" || on_test_failure
assert_response_contains "${url}" "tap accept header" "Count: 2, Failed: 0, Skipped: 0" "application/vnd.goss-documentation" || on_test_failure
assert_response_contains "${url}" "json accept header" "\"failed-count\":0" "application/json" || on_test_failure

[[ "${failure}" == "true" ]] && log_fatal "Test(s) failed, check output above."
132 changes: 101 additions & 31 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package goss

import (
"bytes"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"

"github.com/aelsabbahy/goss/outputs"
"github.com/aelsabbahy/goss/resource"
"github.com/aelsabbahy/goss/system"
"github.com/aelsabbahy/goss/util"
"github.com/fatih/color"
Expand Down Expand Up @@ -48,15 +51,12 @@ func newHealthHandler(c *util.Config) (*healthHandler, error) {
gossMu: &sync.Mutex{},
maxConcurrent: c.MaxConcurrent,
}
if c.OutputFormat == "json" {
health.contentType = "application/json"
}
return health, nil
}

type res struct {
exitCode int
b bytes.Buffer
body bytes.Buffer
statusCode int
}
type healthHandler struct {
c *util.Config
Expand All @@ -65,44 +65,114 @@ type healthHandler struct {
outputer outputs.Outputer
cache *cache.Cache
gossMu *sync.Mutex
contentType string
maxConcurrent int
}

func (h healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
outputConfig := util.OutputConfig{
FormatOptions: h.c.FormatOptions,
outputFormat, outputer, err := h.negotiateResponseContentType(r)
if err != nil {
log.Printf("Warn: Using process-level output-format. %s", err)
outputFormat = h.c.OutputFormat
outputer = h.outputer
}
negotiatedContentType := h.responseContentType(outputFormat)

log.Printf("%v: requesting health probe", r.RemoteAddr)
var resp res
tmp, found := h.cache.Get("res")
resp := h.processAndEnsureCached(negotiatedContentType, outputer)
w.Header().Set(http.CanonicalHeaderKey("Content-Type"), negotiatedContentType)
w.WriteHeader(resp.statusCode)
logBody := ""
if resp.statusCode != http.StatusOK {
logBody = " - " + resp.body.String()
}
resp.body.WriteTo(w)
log.Printf("%v: status %d%s", r.RemoteAddr, resp.statusCode, logBody)
}

func (h healthHandler) processAndEnsureCached(negotiatedContentType string, outputer outputs.Outputer) res {
cacheKey := fmt.Sprintf("res:%s", negotiatedContentType)
tmp, found := h.cache.Get(cacheKey)
if found {
resp = tmp.(res)
return tmp.(res)
}

h.gossMu.Lock()
defer h.gossMu.Unlock()
tmp, found = h.cache.Get(cacheKey)
if found {
log.Printf("Returning cached[%s].", cacheKey)
return tmp.(res)
}

log.Printf("Stale cache[%s], running tests", cacheKey)
resp := h.runValidate(outputer)
h.cache.SetDefault(cacheKey, resp)
return resp
}

func (h healthHandler) runValidate(outputer outputs.Outputer) res {
h.sys = system.New(h.c.PackageManager)
iStartTime := time.Now()
out := validate(h.sys, h.gossConfig, h.maxConcurrent)
var b bytes.Buffer
outputConfig := util.OutputConfig{
FormatOptions: h.c.FormatOptions,
}
exitCode := outputer.Output(&b, out, iStartTime, outputConfig)
resp := res{
body: b,
}
if exitCode == 0 {
resp.statusCode = http.StatusOK
} else {
h.gossMu.Lock()
defer h.gossMu.Unlock()
tmp, found := h.cache.Get("res")
if found {
resp = tmp.(res)
resp.statusCode = http.StatusServiceUnavailable
}
return resp
}

const (
// https://en.wikipedia.org/wiki/Media_type
mediaTypePrefix = "application/vnd.goss-"
)

func (h healthHandler) negotiateResponseContentType(r *http.Request) (string, outputs.Outputer, error) {
acceptHeader := r.Header[http.CanonicalHeaderKey("Accept")]
var outputer outputs.Outputer
outputName := ""
for _, acceptCandidate := range acceptHeader {
acceptCandidate = strings.TrimSpace(acceptCandidate)
if strings.HasPrefix(acceptCandidate, mediaTypePrefix) {
outputName = strings.TrimPrefix(acceptCandidate, mediaTypePrefix)
} else if strings.EqualFold("application/json", acceptCandidate) || strings.EqualFold("text/json", acceptCandidate) {
outputName = "json"
} else {
h.sys = system.New(h.c.PackageManager)
log.Printf("%v: Stale cache, running tests", r.RemoteAddr)
iStartTime := time.Now()
out := validate(h.sys, h.gossConfig, h.maxConcurrent)
var b bytes.Buffer
exitCode := h.outputer.Output(&b, out, iStartTime, outputConfig)
resp = res{exitCode: exitCode, b: b}
h.cache.Set("res", resp, cache.DefaultExpiration)
outputName = ""
}
var err error
outputer, err = outputs.GetOutputer(outputName)
if err != nil {
continue
}
}
if h.contentType != "" {
w.Header().Set("Content-Type", h.contentType)
if outputer == nil {
return "", nil, fmt.Errorf("Accept header on request missing or invalid. Accept header: %v", acceptHeader)
}
if resp.exitCode == 0 {
resp.b.WriteTo(w)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
resp.b.WriteTo(w)

return outputName, outputer, nil
}

func (h healthHandler) responseContentType(outputName string) string {
if outputName == "json" {
return "application/json"
}
return fmt.Sprintf("%s%s", mediaTypePrefix, outputName)
}

func (h healthHandler) renderBody(results <-chan []resource.TestResult, outputer outputs.Outputer) (int, bytes.Buffer) {
outputConfig := util.OutputConfig{
FormatOptions: h.c.FormatOptions,
}
var b bytes.Buffer
exitCode := outputer.Output(&b, results, time.Now(), outputConfig)
return exitCode, b
}
Loading

0 comments on commit e9518bf

Please sign in to comment.