Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle stderr and stdout messages from plugins #1258

Merged
merged 12 commits into from
Feb 7, 2024
2 changes: 2 additions & 0 deletions internal/logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const (
PolicyProvider componentType = "policyProvider"
// Verifier is the component type for verifier.
Verifier componentType = "verifier"
// Plugin is the component type for plugin.
Plugin componentType = "plugin"

traceIDHeaderName = "traceIDHeaderName"
)
Expand Down
72 changes: 68 additions & 4 deletions pkg/common/plugin/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@
package plugin

import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"

"github.com/deislabs/ratify/internal/logger"
"github.com/sirupsen/logrus"
)

Expand All @@ -32,6 +36,10 @@
waitDuration = time.Second
)

var logOpt = logger.Option{
ComponentType: logger.Plugin,
}

// Executor is an interface that defines methods to lookup a plugin and execute it.
type Executor interface {
// ExecutePlugin executes the plugin with the given parameters
Expand Down Expand Up @@ -92,14 +100,39 @@
return nil, e.pluginErr(err, stdout.Bytes(), stderr.Bytes())
}

pluginOutputJSON, pluginOutputMsgs := parsePluginOutput(stdout, stderr)

for _, msg := range pluginOutputMsgs {
msg := strings.ToLower(msg)
switch {
case strings.HasPrefix(msg, "info"):
msg = strings.Replace(msg, "info: ", "", -1)
logger.GetLogger(ctx, logOpt).Infof("[Plugin] %s", msg)
case strings.HasPrefix(msg, "warn"):
msg = strings.Replace(msg, "warn: ", "", -1)
logger.GetLogger(ctx, logOpt).Warnf("[Plugin] %s", msg)
case strings.HasPrefix(msg, "debug"):
msg = strings.Replace(msg, "debug: ", "", -1)
logger.GetLogger(ctx, logOpt).Debugf("[Plugin] %s", msg)

Check warning on line 116 in pkg/common/plugin/exec.go

View check run for this annotation

Codecov / codecov/patch

pkg/common/plugin/exec.go#L108-L116

Added lines #L108 - L116 were not covered by tests
default:
fmt.Fprintf(os.Stderr, "[Plugin] %s,", msg)
}
}

// Copy stderr to caller's buffer in case plugin printed to both
// stdout and stderr for some reason. Ignore failures as stderr is
// only informational.
if e.Stderr != nil && stderr.Len() > 0 {
_, _ = stderr.WriteTo(e.Stderr)
}
// if e.Stderr != nil && stderr.Len() > 0 {
// processStdError(ctx, stderr)
susanshi marked this conversation as resolved.
Show resolved Hide resolved
// //outputPluginMessage(ctx, stderr.String())
// }

//TODO output stdout plugin messages
// jsonBytes := processStdOut(ctx, stdout)

// TODO stdout reader
return stdout.Bytes(), nil
//return processStdOut(ctx, stdout), nil
return pluginOutputJSON, nil
}

func (e *DefaultExecutor) pluginErr(err error, stdout, stderr []byte) error {
Expand All @@ -118,3 +151,34 @@
func (e *DefaultExecutor) FindInPaths(plugin string, paths []string) (string, error) {
return FindInPaths(plugin, paths)
}

func parsePluginOutput(stdout *bytes.Buffer, stderr *bytes.Buffer) ([]byte, []string) {
var obj interface{}
var outputBuffer bytes.Buffer
var jsonBuffer bytes.Buffer
var messages []string

// Combine stderr and stdout
outputBuffer.Write(stderr.Bytes())
outputBuffer.Write(stdout.Bytes())

scanner := bufio.NewScanner(&outputBuffer)
for scanner.Scan() {
line := scanner.Text()
// Assumes a single JSON object is returned somewhere in the output
// does not handle multiple JSON objects
if strings.Contains(line, "{") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious if this could handle log like:

info: test value: {some value}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. The short answer is no. However, I did write code that can handle it. I'm sure it could be improved, but here's what I had come up and then decided against including it to keep it simpler.

	var obj interface{} // Change: Use a generic object
	var resultb bytes.Buffer

	scanner := bufio.NewScanner(&outb)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.Contains(line, "{") {

			err := json.NewDecoder(strings.NewReader(line)).Decode(&obj)
			if err != nil {
				fmt.Println("couldn't decode entire line as JSON....")
				fi := strings.Index(line, "{")
				li := strings.LastIndex(line, "}")

				if fi != -1 || li != -1 {
					err := json.NewDecoder(strings.NewReader(line[fi : li+1])).Decode(&obj)
					if err != nil {
						fmt.Println("failed to decode JSON: ", err)
					}

					pre := line[:fi]
					post := line[li+1:]

					if pre != "" {
						fmt.Println(pre)
					}

					jsonString, _ := json.Marshal(obj)
					resultb.WriteString(string(jsonString))

					if post != "" {
						fmt.Println("post: ", post)
					}
				}
				continue
			}

			jsonString, _ := json.Marshal(obj)
			resultb.WriteString(string(jsonString))

		} else {
			fmt.Printf("Non-JSON line: %s\n", line)
		}
	}

err := json.NewDecoder(strings.NewReader(line)).Decode(&obj)
if err != nil {
continue

Check warning on line 173 in pkg/common/plugin/exec.go

View check run for this annotation

Codecov / codecov/patch

pkg/common/plugin/exec.go#L173

Added line #L173 was not covered by tests
}

jsonString, _ := json.Marshal(obj)
jsonBuffer.WriteString(string(jsonString))
} else {
messages = append(messages, line)
}
}

return jsonBuffer.Bytes(), messages
}
63 changes: 63 additions & 0 deletions pkg/common/plugin/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ limitations under the License.
package plugin

import (
"bytes"
"strings"
"testing"
)
Expand Down Expand Up @@ -44,3 +45,65 @@ func TestPluginErr(t *testing.T) {
t.Fatalf("error msg should contain stdErr msg, actual '%v'", err.Error())
}
}

func TestParsePluginOutput_EmptyBuffers(t *testing.T) {
stdOut := bytes.NewBufferString("")
stdErr := bytes.NewBufferString("")

json, messages := parsePluginOutput(stdOut, stdErr)

if len(messages) != 0 {
t.Fatalf("unexpected messages, expected 0, got %d", len(messages))
}

if len(json) != 0 {
t.Fatalf("unexpected json, expected empty, got '%s'", json)
}
}

func TestParsePluginOutput_StdErrNonEmpty(t *testing.T) {
stdOut := bytes.NewBufferString("")
stdErr := bytes.NewBufferString("This is a string from std err\n")

json, messages := parsePluginOutput(stdOut, stdErr)

if len(messages) != 1 {
t.Fatalf("unexpected messages, expected 1, got %d", len(messages))
}

if len(json) != 0 {
t.Fatalf("unexpected json, expected empty, got '%s'", json)
}
}

func TestParsePluginOutput_StdOutNonEmpty(t *testing.T) {
stdOut := bytes.NewBufferString("{\"key\":\"value\"}\n")
stdErr := bytes.NewBufferString("")

expectedJSON := []byte(`{"key":"value"}`)

json, messages := parsePluginOutput(stdOut, stdErr)

if len(messages) != 0 {
t.Fatalf("unexpected messages, expected 0, got %d", len(messages))
}

if !bytes.Equal(expectedJSON, json) {
t.Fatalf("unexpected json, expected '%s', got '%s'", expectedJSON, json)
}
}

func TestParsePluginOutput_InvalidJson(t *testing.T) {
stdOut := bytes.NewBufferString("This is not a valid json\n")
stdErr := bytes.NewBufferString("")

json, messages := parsePluginOutput(stdOut, stdErr)

if len(messages) != 1 {
t.Fatalf("unexpected messages, expected 1, got %d", len(messages))
}

if len(json) != 0 {
t.Fatalf("unexpected json, expected empty, got '%s'", json)
}
}
70 changes: 70 additions & 0 deletions pkg/common/plugin/logger/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
Copyright The Ratify Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package logger

import (
"fmt"
"io"
"log"
"os"
)

type Logger struct {
infoLogger *log.Logger
debugLogger *log.Logger
warnLogger *log.Logger
}

func NewLogger() *Logger {
return &Logger{
infoLogger: log.New(os.Stderr, "INFO: ", 0),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ratify could swith formatter between text, json and logStash: https://github.com/deislabs/ratify/blob/main/internal/logger/logger.go#L135
just curious if this logger would switch accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @binbin-li, thanks for the comment. I think that would be a good thing to add to match the logging for the rest of the project.

@susanshi @akashsinghal, what do you both think? Worth adding in for this PR?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to log an issue to track this improvement for the plugin log so we could merge this one first? what do you think @binbin-li

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logged an issue to keep track of this feature request. :) #1304

debugLogger: log.New(os.Stderr, "DEBUG: ", 0),
warnLogger: log.New(os.Stderr, "WARN: ", 0),
}
}

func (l *Logger) SetOutput(out io.Writer) {
l.infoLogger.SetOutput(out)
l.debugLogger.SetOutput(out)
l.warnLogger.SetOutput(out)
}

func (l *Logger) Info(message string) {
l.infoLogger.Println(message)
}

func (l *Logger) Infof(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
l.infoLogger.Println(message)
}

func (l *Logger) Debug(message string) {
l.debugLogger.Println(message)
}

func (l *Logger) Debugf(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
l.debugLogger.Println(message)
}

func (l *Logger) Warn(message string) {
l.warnLogger.Println(message)
}

func (l *Logger) Warnf(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
l.warnLogger.Println(message)
}
13 changes: 13 additions & 0 deletions plugins/verifier/sample/sample.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ package main
import (
"encoding/json"
"fmt"
"os"

"github.com/deislabs/ratify/pkg/common"
"github.com/deislabs/ratify/pkg/common/plugin/logger"
"github.com/deislabs/ratify/pkg/ocispecs"
"github.com/deislabs/ratify/pkg/referrerstore"
"github.com/deislabs/ratify/pkg/verifier"
Expand All @@ -37,7 +39,18 @@ type PluginInputConfig struct {
}

func main() {
// create a plugin logger
pluginlogger := logger.NewLogger()

// output to stderr
pluginlogger.Info("initialized plugin")
pluginlogger.Debugf("plugin %s %s", "sample", "1.0.0")

skel.PluginMain("sample", "1.0.0", VerifyReference, []string{"1.0.0"})

// set output to stdout
pluginlogger.SetOutput(os.Stdout)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering when should we use std out vs err

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've seen stderr is the default for messages, but most logging packages provide the option for redirecting to stdout. Tbh, I don't know which is best but wanted to give the flexibility to use both like logrus does. If the team has a strong opinion, I can add comments to nudge people to use stderr, which is the default for the pluginlogger I added.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks for the clarification. :) if we could just add these comment to the sample would be good. Exactly what you have mentioned. " stderr is the default for messages, but we do provide the option to redirecting to stdout"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments added to the latest commit. :) Thanks for the feedback!

pluginlogger.Warn("example warning message...")
susanshi marked this conversation as resolved.
Show resolved Hide resolved
}

func parseInput(stdin []byte) (*PluginConfig, error) {
Expand Down
Loading