mirror of
https://github.com/yannh/kubeconform.git
synced 2026-04-15 16:59:51 +00:00
Add jUnit XML output formatter
This commit is contained in:
parent
a5a34675c0
commit
e6e9eb8698
16 changed files with 3031 additions and 6 deletions
|
|
@ -70,11 +70,11 @@ func FromFlags(progName string, args []string) (Config, string, error) {
|
|||
flags.BoolVar(&c.ExitOnError, "exit-on-error", false, "immediately stop execution when the first error is encountered")
|
||||
flags.BoolVar(&c.IgnoreMissingSchemas, "ignore-missing-schemas", false, "skip files with missing schemas instead of failing")
|
||||
flags.Var(&ignoreFilenamePatterns, "ignore-filename-pattern", "regular expression specifying paths to ignore (can be specified multiple times)")
|
||||
flags.BoolVar(&c.Summary, "summary", false, "print a summary at the end")
|
||||
flags.BoolVar(&c.Summary, "summary", false, "print a summary at the end (ignored for junit output)")
|
||||
flags.IntVar(&c.NumberOfWorkers, "n", 4, "number of goroutines to run concurrently")
|
||||
flags.BoolVar(&c.Strict, "strict", false, "disallow additional properties not in schema")
|
||||
flags.StringVar(&c.OutputFormat, "output", "text", "output format - json, tap, text")
|
||||
flags.BoolVar(&c.Verbose, "verbose", false, "print results for all resources (ignored for tap output)")
|
||||
flags.StringVar(&c.OutputFormat, "output", "text", "output format - json, junit, tap, text")
|
||||
flags.BoolVar(&c.Verbose, "verbose", false, "print results for all resources (ignored for tap and junit output)")
|
||||
flags.BoolVar(&c.SkipTLS, "insecure-skip-tls-verify", false, "disable verification of the server's SSL certificate. This will make your HTTPS connections insecure")
|
||||
flags.StringVar(&c.Cache, "cache", "", "cache schemas downloaded via HTTP to this folder")
|
||||
flags.StringVar(&c.CPUProfileFile, "cpu-prof", "", "debug - log CPU profiling to file")
|
||||
|
|
|
|||
176
pkg/output/junit.go
Normal file
176
pkg/output/junit.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package output
|
||||
|
||||
// References:
|
||||
// https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd
|
||||
// https://llg.cubic.org/docs/junit/
|
||||
// https://github.com/jstemmer/go-junit-report/blob/master/formatter/formatter.go
|
||||
// https://github.com/junit-team/junit5/blob/main/platform-tests/src/test/resources/jenkins-junit.xsd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"github.com/yannh/kubeconform/pkg/validator"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TestSuiteCollection struct {
|
||||
XMLName xml.Name `xml:"testsuites"`
|
||||
Name string `xml:"name,attr"`
|
||||
Time float64 `xml:"time,attr"`
|
||||
Tests int `xml:"tests,attr"`
|
||||
Failures int `xml:"failures,attr"`
|
||||
Disabled int `xml:"disabled,attr"`
|
||||
Errors int `xml:"errors,attr"`
|
||||
Suites []TestSuite `xml:"testsuite"`
|
||||
}
|
||||
|
||||
type Property struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"value,attr"`
|
||||
}
|
||||
|
||||
type TestSuite struct {
|
||||
XMLName xml.Name `xml:"testsuite"`
|
||||
Properties []*Property `xml:"properties>property,omitempty"`
|
||||
Cases []TestCase `xml:"testcase"`
|
||||
Name string `xml:"name,attr"`
|
||||
Id int `xml:"id,attr"`
|
||||
Tests int `xml:"tests,attr"`
|
||||
Failures int `xml:"failures,attr"`
|
||||
Errors int `xml:"errors,attr"`
|
||||
Disabled int `xml:"disabled,attr"`
|
||||
Skipped int `xml:"skipped,attr"`
|
||||
}
|
||||
|
||||
type TestCase struct {
|
||||
XMLName xml.Name `xml:"testcase"`
|
||||
Name string `xml:"name,attr"`
|
||||
ClassName string `xml:"classname,attr"`
|
||||
Skipped *TestCaseSkipped `xml:"skipped,omitempty"`
|
||||
Error *TestCaseError `xml:"error,omitempty"`
|
||||
Failure []TestCaseError `xml:"failure,omitempty"`
|
||||
}
|
||||
|
||||
type TestCaseSkipped struct {
|
||||
Message string `xml:"message,attr"`
|
||||
}
|
||||
|
||||
type TestCaseError struct {
|
||||
Message string `xml:"message,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Content string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type junito struct {
|
||||
id int
|
||||
w io.Writer
|
||||
withSummary bool
|
||||
verbose bool
|
||||
suites map[string]*TestSuite // map filename to corresponding suite
|
||||
nValid, nInvalid, nErrors, nSkipped int
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
func junitOutput(w io.Writer, withSummary bool, isStdin, verbose bool) Output {
|
||||
return &junito{
|
||||
id: 0,
|
||||
w: w,
|
||||
withSummary: withSummary,
|
||||
verbose: verbose,
|
||||
suites: make(map[string]*TestSuite),
|
||||
nValid: 0,
|
||||
nInvalid: 0,
|
||||
nErrors: 0,
|
||||
nSkipped: 0,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Write adds a result to the report.
|
||||
func (o *junito) Write(result validator.Result) error {
|
||||
var suite *TestSuite
|
||||
suite, found := o.suites[result.Resource.Path]
|
||||
|
||||
if !found {
|
||||
o.id++
|
||||
suite = &TestSuite{
|
||||
Name: result.Resource.Path,
|
||||
Id: o.id,
|
||||
Tests: 0, Failures: 0, Errors: 0, Disabled: 0, Skipped: 0,
|
||||
Cases: make([]TestCase, 0),
|
||||
Properties: make([]*Property, 0),
|
||||
}
|
||||
o.suites[result.Resource.Path] = suite
|
||||
}
|
||||
|
||||
suite.Tests++
|
||||
|
||||
sig, _ := result.Resource.Signature()
|
||||
var objectName string
|
||||
if len(sig.Namespace) > 0 {
|
||||
objectName = fmt.Sprintf("%s/%s", sig.Namespace, sig.Name)
|
||||
} else {
|
||||
objectName = sig.Name
|
||||
}
|
||||
typeName := fmt.Sprintf("%s@%s", sig.Kind, sig.Version)
|
||||
testCase := TestCase{ClassName: typeName, Name: objectName}
|
||||
|
||||
switch result.Status {
|
||||
case validator.Valid:
|
||||
o.nValid++
|
||||
case validator.Invalid:
|
||||
suite.Failures++
|
||||
o.nInvalid++
|
||||
failure := TestCaseError{Message: result.Err.Error()}
|
||||
testCase.Failure = append(testCase.Failure, failure)
|
||||
case validator.Error:
|
||||
suite.Errors++
|
||||
o.nErrors++
|
||||
testCase.Error = &TestCaseError{Message: result.Err.Error()}
|
||||
case validator.Skipped:
|
||||
suite.Skipped++
|
||||
testCase.Skipped = &TestCaseSkipped{}
|
||||
o.nSkipped++
|
||||
case validator.Empty:
|
||||
}
|
||||
|
||||
suite.Cases = append(suite.Cases, testCase)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush outputs the results as XML
|
||||
func (o *junito) Flush() error {
|
||||
runtime := time.Now().Sub(o.startTime)
|
||||
|
||||
var suites = make([]TestSuite, 0)
|
||||
for _, suite := range o.suites {
|
||||
suites = append(suites, *suite)
|
||||
}
|
||||
|
||||
root := TestSuiteCollection{
|
||||
Name: "kubeconform",
|
||||
Time: runtime.Seconds(),
|
||||
Tests: o.nValid + o.nInvalid + o.nErrors + o.nSkipped,
|
||||
Failures: o.nInvalid,
|
||||
Errors: o.nErrors,
|
||||
Disabled: o.nSkipped,
|
||||
Suites: suites,
|
||||
}
|
||||
|
||||
// 2-space indentation
|
||||
content, err := xml.MarshalIndent(root, "", " ")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := bufio.NewWriter(o.w)
|
||||
writer.Write(content)
|
||||
writer.WriteByte('\n')
|
||||
writer.Flush()
|
||||
|
||||
return nil
|
||||
}
|
||||
168
pkg/output/junit_test.go
Normal file
168
pkg/output/junit_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/yannh/kubeconform/pkg/resource"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/yannh/kubeconform/pkg/validator"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
)
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
matched, _ := regexp.MatchString("^\\d+(\\.\\d+)?$", s)
|
||||
return matched
|
||||
}
|
||||
|
||||
func TestJUnitWrite(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
withSummary bool
|
||||
isStdin bool
|
||||
verbose bool
|
||||
results []validator.Result
|
||||
evaluate func(d *etree.Document)
|
||||
}{
|
||||
{
|
||||
"empty document",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
[]validator.Result{},
|
||||
func(d *etree.Document) {
|
||||
root := d.FindElement("/testsuites")
|
||||
if root == nil {
|
||||
t.Errorf("Can't find root testsuite element")
|
||||
return
|
||||
}
|
||||
for _, attr := range root.Attr {
|
||||
switch attr.Key {
|
||||
case "time":
|
||||
case "tests":
|
||||
case "failures":
|
||||
case "disabled":
|
||||
case "errors":
|
||||
if !isNumeric(attr.Value) {
|
||||
t.Errorf("Expected a number for /testsuites/@%s", attr.Key)
|
||||
}
|
||||
continue
|
||||
case "name":
|
||||
if attr.Value != "kubeconform" {
|
||||
t.Errorf("Expected 'kubeconform' for /testsuites/@name")
|
||||
}
|
||||
continue
|
||||
default:
|
||||
t.Errorf("Unknown attribute /testsuites/@%s", attr.Key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
suites := root.SelectElements("testsuite")
|
||||
if len(suites) != 0 {
|
||||
t.Errorf("No testsuite elements should be generated when there are no resources")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"a single deployment, verbose, with summary",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
[]validator.Result{
|
||||
{
|
||||
Resource: resource.Resource{
|
||||
Path: "deployment.yml",
|
||||
Bytes: []byte(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: "my-app"
|
||||
namespace: "my-namespace"
|
||||
`),
|
||||
},
|
||||
Status: validator.Valid,
|
||||
Err: nil,
|
||||
},
|
||||
},
|
||||
func(d *etree.Document) {
|
||||
suites := d.FindElements("//testsuites/testsuite")
|
||||
if len(suites) != 1 {
|
||||
t.Errorf("Expected exactly 1 testsuite element, got %d", len(suites))
|
||||
return
|
||||
}
|
||||
suite := suites[0]
|
||||
for _, attr := range suite.Attr {
|
||||
switch attr.Key {
|
||||
case "name":
|
||||
if attr.Value != "deployment.yml" {
|
||||
t.Errorf("Test suite name should be the resource path")
|
||||
}
|
||||
continue
|
||||
case "tests":
|
||||
if attr.Value != "1" {
|
||||
t.Errorf("testsuite/@tests should be 1")
|
||||
}
|
||||
continue
|
||||
case "failures":
|
||||
if attr.Value != "0" {
|
||||
t.Errorf("testsuite/@failures should be 0")
|
||||
}
|
||||
continue
|
||||
case "errors":
|
||||
if attr.Value != "0" {
|
||||
t.Errorf("testsuite/@errors should be 0")
|
||||
}
|
||||
continue
|
||||
case "disabled":
|
||||
if attr.Value != "0" {
|
||||
t.Errorf("testsuite/@disabled should be 0")
|
||||
}
|
||||
continue
|
||||
case "skipped":
|
||||
if attr.Value != "0" {
|
||||
t.Errorf("testsuite/@skipped should be 0")
|
||||
}
|
||||
continue
|
||||
default:
|
||||
t.Errorf("Unknown testsuite attribute %s", attr.Key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
testcases := suite.SelectElements("testcase")
|
||||
if len(testcases) != 1 {
|
||||
t.Errorf("Expected exactly 1 testcase, got %d", len(testcases))
|
||||
return
|
||||
}
|
||||
testcase := testcases[0]
|
||||
if testcase.SelectAttrValue("name", "") != "my-namespace/my-app" {
|
||||
t.Errorf("Test case name should be namespace / name")
|
||||
}
|
||||
if testcase.SelectAttrValue("classname", "") != "Deployment@apps/v1" {
|
||||
t.Errorf("Test case class name should be resource kind @ api version")
|
||||
}
|
||||
if testcase.SelectElement("skipped") != nil {
|
||||
t.Errorf("skipped element should not be generated if the kind was not skipped")
|
||||
}
|
||||
if testcase.SelectElement("error") != nil {
|
||||
t.Errorf("error element should not be generated if there was no error")
|
||||
}
|
||||
if len(testcase.SelectElements("failure")) != 0 {
|
||||
t.Errorf("failure elements should not be generated if there were no failures")
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
w := new(bytes.Buffer)
|
||||
o := junitOutput(w, testCase.withSummary, testCase.isStdin, testCase.verbose)
|
||||
|
||||
for _, res := range testCase.results {
|
||||
o.Write(res)
|
||||
}
|
||||
o.Flush()
|
||||
|
||||
doc := etree.NewDocument()
|
||||
doc.ReadFromString(w.String())
|
||||
|
||||
testCase.evaluate(doc)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ func New(outputFormat string, printSummary, isStdin, verbose bool) (Output, erro
|
|||
switch {
|
||||
case outputFormat == "json":
|
||||
return jsonOutput(w, printSummary, isStdin, verbose), nil
|
||||
case outputFormat == "junit":
|
||||
return junitOutput(w, printSummary, isStdin, verbose), nil
|
||||
case outputFormat == "tap":
|
||||
return tapOutput(w, printSummary, isStdin, verbose), nil
|
||||
case outputFormat == "text":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue