diff --git a/pkg/minikube/exit/exit.go b/pkg/minikube/exit/exit.go index 4f9f734b2c9e..7b2c50ae4b2f 100644 --- a/pkg/minikube/exit/exit.go +++ b/pkg/minikube/exit/exit.go @@ -43,13 +43,13 @@ const ( // UsageT outputs a templated usage error and exits with error code 64 func UsageT(format string, a ...out.V) { - out.ErrT(out.Usage, format, a...) + out.ErrWithExitCode(out.Usage, format, BadUsage, a...) os.Exit(BadUsage) } // WithCodeT outputs a templated fatal error message and exits with the supplied error code. func WithCodeT(code int, format string, a ...out.V) { - out.FatalT(format, a...) + out.ErrWithExitCode(out.FatalType, format, code, a...) os.Exit(code) } @@ -57,8 +57,12 @@ func WithCodeT(code int, format string, a ...out.V) { func WithError(msg string, err error) { glog.Infof("WithError(%s)=%v called from:\n%s", msg, err, debug.Stack()) p := problem.FromError(err, runtime.GOOS) - if p != nil { + if p != nil && out.JSON { + p.DisplayJSON(Config) + os.Exit(Config) + } else { WithProblem(msg, err, p) + os.Exit(Config) } out.DisplayError(msg, err) os.Exit(Software) @@ -74,5 +78,4 @@ func WithProblem(msg string, err error, p *problem.Problem) { out.ErrT(out.Sad, "If the above advice does not help, please let us know: ") out.ErrT(out.URL, "https://github.com/kubernetes/minikube/issues/new/choose") } - os.Exit(Config) } diff --git a/pkg/minikube/out/out.go b/pkg/minikube/out/out.go index 38fccc13ca38..908c29bc3867 100644 --- a/pkg/minikube/out/out.go +++ b/pkg/minikube/out/out.go @@ -115,6 +115,16 @@ func Ln(format string, a ...interface{}) { String(format+"\n", a...) } +// ErrWithExitCode includes the exit code in JSON output +func ErrWithExitCode(style StyleEnum, format string, exitcode int, a ...V) { + if JSON { + errStyled := ApplyTemplateFormatting(style, useColor, format, a...) + register.PrintErrorExitCode(errStyled, exitcode) + return + } + ErrT(style, format, a...) +} + // ErrT writes a stylized and templated error message to stderr func ErrT(style StyleEnum, format string, a ...V) { errStyled := ApplyTemplateFormatting(style, useColor, format, a...) @@ -123,6 +133,10 @@ func ErrT(style StyleEnum, format string, a ...V) { // Err writes a basic formatted string to stderr func Err(format string, a ...interface{}) { + if JSON { + register.PrintError(format) + return + } if errFile == nil { glog.Errorf("[unset errFile]: %s", fmt.Sprintf(format, a...)) return @@ -232,8 +246,12 @@ func LogEntries(msg string, err error, entries map[string][]string) { // DisplayError prints the error and displays the standard minikube error messaging func DisplayError(msg string, err error) { - // use Warning because Error will display a duplicate message to stderr glog.Warningf(fmt.Sprintf("%s: %v", msg, err)) + if JSON { + FatalT("{{.msg}}: {{.err}}", V{"msg": translate.T(msg), "err": err}) + return + } + // use Warning because Error will display a duplicate message to stderr ErrT(Empty, "") FatalT("{{.msg}}: {{.err}}", V{"msg": translate.T(msg), "err": err}) ErrT(Empty, "") diff --git a/pkg/minikube/out/register/cloud_events.go b/pkg/minikube/out/register/cloud_events.go index ead8240c1b24..33dd089f7dc7 100644 --- a/pkg/minikube/out/register/cloud_events.go +++ b/pkg/minikube/out/register/cloud_events.go @@ -31,8 +31,8 @@ const ( ) var ( - outputFile io.Writer = os.Stdout - getUUID = randomID + OutputFile io.Writer = os.Stdout + GetUUID = randomID ) func printAsCloudEvent(log Log, data map[string]string) { @@ -43,12 +43,12 @@ func printAsCloudEvent(log Log, data map[string]string) { if err := event.SetData(cloudevents.ApplicationJSON, data); err != nil { glog.Warningf("error setting data: %v", err) } - event.SetID(getUUID()) + event.SetID(GetUUID()) json, err := event.MarshalJSON() if err != nil { glog.Warningf("error marashalling event: %v", err) } - fmt.Fprintln(outputFile, string(json)) + fmt.Fprintln(OutputFile, string(json)) } func randomID() string { diff --git a/pkg/minikube/out/register/json.go b/pkg/minikube/out/register/json.go index b1b0549bf18b..06fae5980d70 100644 --- a/pkg/minikube/out/register/json.go +++ b/pkg/minikube/out/register/json.go @@ -40,6 +40,18 @@ func PrintDownloadProgress(artifact, progress string) { printAsCloudEvent(s, s.data) } +// PrintError prints an Error type in JSON format +func PrintError(err string) { + e := NewError(err) + printAsCloudEvent(e, e.data) +} + +// PrintErrorExitCode prints an error in JSON format and includes an exit code +func PrintErrorExitCode(err string, exitcode int, additionalArgs ...map[string]string) { + e := NewErrorExitCode(err, exitcode, additionalArgs...) + printAsCloudEvent(e, e.data) +} + // PrintWarning prints a Warning type in JSON format func PrintWarning(warning string) { w := NewWarning(warning) diff --git a/pkg/minikube/out/register/json_test.go b/pkg/minikube/out/register/json_test.go index 31e4be288075..0d1fb3a20843 100644 --- a/pkg/minikube/out/register/json_test.go +++ b/pkg/minikube/out/register/json_test.go @@ -29,10 +29,10 @@ func TestPrintStep(t *testing.T) { expected += "\n" buf := bytes.NewBuffer([]byte{}) - outputFile = buf - defer func() { outputFile = os.Stdout }() + OutputFile = buf + defer func() { OutputFile = os.Stdout }() - getUUID = func() string { + GetUUID = func() string { return "random-id" } @@ -49,10 +49,10 @@ func TestPrintInfo(t *testing.T) { expected += "\n" buf := bytes.NewBuffer([]byte{}) - outputFile = buf - defer func() { outputFile = os.Stdout }() + OutputFile = buf + defer func() { OutputFile = os.Stdout }() - getUUID = func() string { + GetUUID = func() string { return "random-id" } @@ -64,15 +64,53 @@ func TestPrintInfo(t *testing.T) { } } +func TestError(t *testing.T) { + expected := `{"data":{"message":"error"},"datacontenttype":"application/json","id":"random-id","source":"https://minikube.sigs.k8s.io/","specversion":"1.0","type":"io.k8s.sigs.minikube.error"}` + expected += "\n" + + buf := bytes.NewBuffer([]byte{}) + OutputFile = buf + defer func() { OutputFile = os.Stdout }() + + GetUUID = func() string { + return "random-id" + } + + PrintError("error") + actual := buf.String() + + if actual != expected { + t.Fatalf("expected didn't match actual:\nExpected:\n%v\n\nActual:\n%v", expected, actual) + } +} + +func TestErrorExitCode(t *testing.T) { + expected := `{"data":{"a":"b","c":"d","exitcode":"5","message":"error"},"datacontenttype":"application/json","id":"random-id","source":"https://minikube.sigs.k8s.io/","specversion":"1.0","type":"io.k8s.sigs.minikube.error"}` + expected += "\n" + + buf := bytes.NewBuffer([]byte{}) + OutputFile = buf + defer func() { OutputFile = os.Stdout }() + + GetUUID = func() string { + return "random-id" + } + + PrintErrorExitCode("error", 5, map[string]string{"a": "b"}, map[string]string{"c": "d"}) + actual := buf.String() + if actual != expected { + t.Fatalf("expected didn't match actual:\nExpected:\n%v\n\nActual:\n%v", expected, actual) + } +} func TestWarning(t *testing.T) { expected := `{"data":{"message":"warning"},"datacontenttype":"application/json","id":"random-id","source":"https://minikube.sigs.k8s.io/","specversion":"1.0","type":"io.k8s.sigs.minikube.warning"}` expected += "\n" buf := bytes.NewBuffer([]byte{}) - outputFile = buf - defer func() { outputFile = os.Stdout }() + OutputFile = buf + defer func() { OutputFile = os.Stdout }() - getUUID = func() string { + GetUUID = func() string { return "random-id" } diff --git a/pkg/minikube/out/register/log.go b/pkg/minikube/out/register/log.go index 0f39086a924a..67b24885f504 100644 --- a/pkg/minikube/out/register/log.go +++ b/pkg/minikube/out/register/log.go @@ -16,6 +16,8 @@ limitations under the License. package register +import "fmt" + // Log represents the different types of logs that can be output as JSON // This includes: Step, Download, DownloadProgress, Warning, Info, Error type Log interface { @@ -120,6 +122,27 @@ func NewInfo(message string) *Info { // Error will be used to notify the user of errors type Error struct { + data map[string]string +} + +func NewError(err string) *Error { + return &Error{ + map[string]string{ + "message": err, + }, + } +} + +// NewErrorExitCode returns an error that has an associated exit code +func NewErrorExitCode(err string, exitcode int, additionalData ...map[string]string) *Error { + e := NewError(err) + e.data["exitcode"] = fmt.Sprintf("%v", exitcode) + for _, a := range additionalData { + for k, v := range a { + e.data[k] = v + } + } + return e } func (s *Error) Type() string { diff --git a/pkg/minikube/out/register/register_test.go b/pkg/minikube/out/register/register_test.go index 0b70b01a803c..e40c7a6678c7 100644 --- a/pkg/minikube/out/register/register_test.go +++ b/pkg/minikube/out/register/register_test.go @@ -32,10 +32,10 @@ func TestSetCurrentStep(t *testing.T) { expected += "\n" buf := bytes.NewBuffer([]byte{}) - outputFile = buf - defer func() { outputFile = os.Stdout }() + OutputFile = buf + defer func() { OutputFile = os.Stdout }() - getUUID = func() string { + GetUUID = func() string { return "random-id" } diff --git a/pkg/minikube/problem/problem.go b/pkg/minikube/problem/problem.go index d5465a183041..ae7b62eb8e58 100644 --- a/pkg/minikube/problem/problem.go +++ b/pkg/minikube/problem/problem.go @@ -22,6 +22,7 @@ import ( "regexp" "k8s.io/minikube/pkg/minikube/out" + "k8s.io/minikube/pkg/minikube/out/register" "k8s.io/minikube/pkg/minikube/translate" ) @@ -80,6 +81,21 @@ func (p *Problem) Display() { } } +// DisplayJSON displays problem metadata in JSON format +func (p *Problem) DisplayJSON(exitcode int) { + var issues string + for _, i := range p.Issues { + issues += fmt.Sprintf("https://github.com/kubernetes/minikube/issues/%v,", i) + } + extraArgs := map[string]string{ + "name": p.ID, + "advice": p.Advice, + "url": p.URL, + "issues": issues, + } + register.PrintErrorExitCode(p.Err.Error(), exitcode, extraArgs) +} + // FromError returns a known problem from an error on an OS func FromError(err error, goos string) *Problem { maps := []map[string]match{ diff --git a/pkg/minikube/problem/problem_test.go b/pkg/minikube/problem/problem_test.go index d9542718014a..c0134080dd22 100644 --- a/pkg/minikube/problem/problem_test.go +++ b/pkg/minikube/problem/problem_test.go @@ -19,10 +19,12 @@ package problem import ( "bytes" "fmt" + "os" "strings" "testing" "k8s.io/minikube/pkg/minikube/out" + "k8s.io/minikube/pkg/minikube/out/register" ) type buffFd struct { @@ -96,6 +98,45 @@ func TestDisplay(t *testing.T) { } } +func TestDisplayJSON(t *testing.T) { + defer out.SetJSON(false) + out.SetJSON(true) + + tcs := []struct { + p *Problem + expected string + }{ + { + p: &Problem{ + Err: fmt.Errorf("my error"), + Advice: "fix me!", + Issues: []int{1, 2}, + URL: "url", + ID: "BUG", + }, + expected: `{"data":{"advice":"fix me!","exitcode":"4","issues":"https://github.com/kubernetes/minikube/issues/1,https://github.com/kubernetes/minikube/issues/2,","message":"my error","name":"BUG","url":"url"},"datacontenttype":"application/json","id":"random-id","source":"https://minikube.sigs.k8s.io/","specversion":"1.0","type":"io.k8s.sigs.minikube.error"} +`, + }, + } + for _, tc := range tcs { + t.Run(tc.p.ID, func(t *testing.T) { + buf := bytes.NewBuffer([]byte{}) + register.OutputFile = buf + defer func() { register.OutputFile = os.Stdout }() + + register.GetUUID = func() string { + return "random-id" + } + + tc.p.DisplayJSON(4) + actual := buf.String() + if actual != tc.expected { + t.Fatalf("expected didn't match actual:\nExpected:\n%v\n\nActual:\n%v", tc.expected, actual) + } + }) + } +} + func TestFromError(t *testing.T) { var tests = []struct { issue int diff --git a/test/integration/json_output_test.go b/test/integration/json_output_test.go index f636f56134cf..38db922a7d8f 100644 --- a/test/integration/json_output_test.go +++ b/test/integration/json_output_test.go @@ -19,17 +19,18 @@ package integration import ( "context" "encoding/json" + "fmt" "os/exec" + "runtime" "strings" "testing" cloudevents "github.com/cloudevents/sdk-go/v2" + "k8s.io/minikube/pkg/minikube/exit" + "k8s.io/minikube/pkg/minikube/out/register" ) func TestJSONOutput(t *testing.T) { - if NoneDriver() || DockerDriver() { - t.Skipf("skipping: test drivers once all JSON output is enabled") - } profile := UniqueProfileName("json-output") ctx, cancel := context.WithTimeout(context.Background(), Minutes(40)) defer Cleanup(t, profile, cancel) @@ -59,8 +60,61 @@ func TestJSONOutput(t *testing.T) { } -// make sure all output can be marshaled as a cloud event -func validateCloudEvents(ctx context.Context, t *testing.T, rr *RunResult) { +func TestJSONOutputError(t *testing.T) { + profile := UniqueProfileName("json-output-error") + ctx, cancel := context.WithTimeout(context.Background(), Minutes(2)) + defer Cleanup(t, profile, cancel) + + // force a failure via --driver=fail so that we can make sure errors + // are printed as expected + startArgs := []string{"start", "-p", profile, "--memory=2200", "--output=json", "--wait=true", "--driver=fail"} + + rr, err := Run(t, exec.CommandContext(ctx, Target(), startArgs...)) + if err == nil { + t.Errorf("expected failure: args %q: %v", rr.Command(), err) + } + ces, err := cloudEvents(t, rr) + if err != nil { + t.Fatal(err) + } + // we want the last cloud event to be of type error and have the expected exit code and message + last := newCloudEvent(t, ces[len(ces)-1]) + if last.Type() != register.NewError("").Type() { + t.Fatalf("last cloud event is not of type error: %v", last) + } + last.validateData(t, "exitcode", fmt.Sprintf("%v", exit.Unavailable)) + last.validateData(t, "message", fmt.Sprintf("The driver 'fail' is not supported on %s\n", runtime.GOOS)) +} + +type cloudEvent struct { + cloudevents.Event + data map[string]string +} + +func newCloudEvent(t *testing.T, ce cloudevents.Event) *cloudEvent { + m := map[string]string{} + data := ce.Data() + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("marshalling cloud event: %v", err) + } + return &cloudEvent{ + Event: ce, + data: m, + } +} + +func (c *cloudEvent) validateData(t *testing.T, key, value string) { + v, ok := c.data[key] + if !ok { + t.Fatalf("expected key %s does not exist in cloud event", key) + } + if v != value { + t.Fatalf("values in cloud events do not match:\nActual:\n%v\nExpected:\n%v\n", v, value) + } +} + +func cloudEvents(t *testing.T, rr *RunResult) ([]cloudevents.Event, error) { + ces := []cloudevents.Event{} stdout := strings.Split(rr.Stdout.String(), "\n") for _, s := range stdout { if s == "" { @@ -68,7 +122,18 @@ func validateCloudEvents(ctx context.Context, t *testing.T, rr *RunResult) { } event := cloudevents.NewEvent() if err := json.Unmarshal([]byte(s), &event); err != nil { - t.Fatalf("unable to unmarshal output: %v\n%s", err, s) + t.Logf("unable to marshal output: %v", s) + return nil, err } + ces = append(ces, event) + } + return ces, nil +} + +// make sure all output can be marshaled as a cloud event +func validateCloudEvents(ctx context.Context, t *testing.T, rr *RunResult) { + _, err := cloudEvents(t, rr) + if err != nil { + t.Fatalf("converting to cloud events: %v\n", err) } }