Repository: GoogleCloudPlatform/terraform-validator
Pull request #304 (2021-09-13 21:16:13)

Basic implementation of structured logging
Implemented the first steps of https://github.com/GoogleCloudPlatform/terraform-validator/issues/255. Essentially:

- Added in zap loggers to output logs in a structured way.
  - Use the zap loggers in all cases for errors, but default to Console logging
  - Continue defaulting to fmt.Printf for command output.
  - If the user passes a specific env var, switch to structured logging for errors and command output.
- Redirected stdlib logging to zap to capture it in a structured way as well
- Made validate / convert option structs store additional functions that we can set to mocks, so we can write unit tests for the commands

Remaining work after this PR will be to pipe the loggers through to the convert/validate internals and remove remaining usages of fmt.Printf, log, and glog.

As part of this work, I also turned on SilenceUsage and SilenceErrors at the root command, since that output is not generally useful and can't be made structured (since it's printed directly to stdout.) We instead log any returned errors in the main Execute func.


Patch:
diff --git a/cmd/convert.go b/cmd/convert.go
index 9f087e97..bb1bc64c 100644
--- a/cmd/convert.go
+++ b/cmd/convert.go
@@ -22,6 +22,7 @@ import (
 	"github.com/GoogleCloudPlatform/terraform-validator/tfgcv"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
+	"go.uber.org/zap"
 )
 
 const convertDesc = `
@@ -40,19 +41,27 @@ Example:
 `
 
 type convertOptions struct {
-	project  string
-	ancestry string
-	offline  bool
+	project              string
+	ancestry             string
+	offline              bool
+	errorLogger          *zap.Logger
+	outputLogger         *zap.Logger
+	useStructuredLogging bool
+	readPlannedAssets    tfgcv.ReadPlannedAssetsFunc
 }
 
-func newConvertCmd() *cobra.Command {
-	o := &convertOptions{}
+func newConvertCmd(errorLogger, outputLogger *zap.Logger, useStructuredLogging bool) *cobra.Command {
+	o := &convertOptions{
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    tfgcv.ReadPlannedAssets,
+	}
 
 	cmd := &cobra.Command{
 		Use:   "convert TFPLAN_JSON",
 		Short: "convert a Terraform plan to Google CAI assets",
 		Long:  convertDesc,
-		SilenceUsage: os.Getenv("COBRA_SILENCE_USAGE") == "true",
 		PreRunE: func(c *cobra.Command, args []string) error {
 			return o.validateArgs(args)
 		},
@@ -80,7 +89,7 @@ func (o *convertOptions) validateArgs(args []string) error {
 
 func (o *convertOptions) run(plan string) error {
 	ctx := context.Background()
-	assets, err := tfgcv.ReadPlannedAssets(ctx, plan, o.project, o.ancestry, o.offline, false)
+	assets, err := o.readPlannedAssets(ctx, plan, o.project, o.ancestry, o.offline, false)
 	if err != nil {
 		if errors.Cause(err) == tfgcv.ErrParsingProviderProject {
 			return errors.New("unable to parse provider project, please use --project flag")
@@ -88,6 +97,15 @@ func (o *convertOptions) run(plan string) error {
 		return errors.Wrap(err, "converting tfplan to CAI assets")
 	}
 
+	if o.useStructuredLogging {
+		o.outputLogger.Info(
+			"converted resources",
+			zap.Any("resource_body", assets),
+		)
+		return nil
+	}
+
+	// Legacy behavior
 	if err := json.NewEncoder(os.Stdout).Encode(assets); err != nil {
 		return errors.Wrap(err, "encoding json")
 	}
diff --git a/cmd/errors.go b/cmd/errors.go
new file mode 100644
index 00000000..7a0c6520
--- /dev/null
+++ b/cmd/errors.go
@@ -0,0 +1,21 @@
+// Copyright 2021 Google LLC
+//
+// 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 cmd
+
+import (
+	"github.com/pkg/errors"
+)
+
+var errViolations = errors.New("Found violations")
diff --git a/cmd/list_supported_resources.go b/cmd/list_supported_resources.go
index 285ea8de..3af4ffc2 100644
--- a/cmd/list_supported_resources.go
+++ b/cmd/list_supported_resources.go
@@ -16,13 +16,12 @@ package cmd
 
 import (
 	"fmt"
-	"os"
 
 	converter "github.com/GoogleCloudPlatform/terraform-google-conversion/google"
 	"github.com/spf13/cobra"
 )
 
-type listSupportedResourcesOptions struct {}
+type listSupportedResourcesOptions struct{}
 
 func newListSupportedResourcesCmd() *cobra.Command {
 	o := listSupportedResourcesOptions{}
@@ -30,7 +29,6 @@ func newListSupportedResourcesCmd() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "list-supported-resources",
 		Short: "List supported terraform resources.",
-		SilenceUsage: os.Getenv("COBRA_SILENCE_USAGE") == "true",
 		RunE: func(c *cobra.Command, args []string) error {
 			return o.run()
 		},
diff --git a/cmd/logger.go b/cmd/logger.go
new file mode 100644
index 00000000..db241fda
--- /dev/null
+++ b/cmd/logger.go
@@ -0,0 +1,130 @@
+// Copyright 2021 Google LLC
+//
+// 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 cmd
+
+import (
+	"os"
+
+	"go.uber.org/zap"
+	"go.uber.org/zap/buffer"
+	"go.uber.org/zap/zapcore"
+)
+
+type errorDetails struct {
+	// error message
+	error string
+	// stacktrace or additional context
+	context string
+}
+
+func (ed errorDetails) MarshalLogObject(enc zapcore.ObjectEncoder) error {
+	enc.AddString("error", ed.error)
+	enc.AddString("context", ed.context)
+	return nil
+}
+
+type errorEncoder struct {
+	zapcore.Encoder
+}
+
+func (enc errorEncoder) Clone() zapcore.Encoder {
+	return errorEncoder{
+		Encoder: enc.Encoder.Clone(),
+	}
+}
+
+func (enc errorEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
+	ed := errorDetails{
+		error:   ent.Message,
+		context: ent.Stack,
+	}
+	fields = append([]zapcore.Field{
+		zap.Object("error_details", ed),
+	}, fields...)
+	return enc.Encoder.EncodeEntry(ent, fields)
+}
+
+func newJSONEncoder(cfg zapcore.EncoderConfig) errorEncoder {
+	return errorEncoder{
+		Encoder: zapcore.NewJSONEncoder(cfg),
+	}
+}
+
+func newConsoleEncoder(cfg zapcore.EncoderConfig) errorEncoder {
+	return errorEncoder{
+		Encoder: zapcore.NewConsoleEncoder(cfg),
+	}
+}
+
+func newErrorLogger(verbose, useStructuredLogging bool) *zap.Logger {
+	// Return a logger that produces expected structured output format for errors
+	var level zap.AtomicLevel
+	options := []zap.Option{
+		zap.Fields(
+			// Message format version
+			zap.String("version", "v1.0.0"),
+		),
+	}
+
+	if verbose {
+		level = zap.NewAtomicLevelAt(zap.DebugLevel)
+		options = append(options, zap.AddStacktrace(zap.WarnLevel))
+	} else {
+		level = zap.NewAtomicLevelAt(zap.InfoLevel)
+		options = append(options, zap.AddStacktrace(zap.ErrorLevel))
+	}
+
+	encoderConfig := zapcore.EncoderConfig{
+		TimeKey:       "timestamp",
+		LevelKey:      "level",
+		MessageKey:    "",
+		StacktraceKey: "",
+		EncodeLevel:   zapcore.LowercaseLevelEncoder,
+		EncodeTime:    zapcore.RFC3339NanoTimeEncoder,
+	}
+	var encoder errorEncoder
+	if useStructuredLogging {
+		encoder = newJSONEncoder(encoderConfig)
+	} else {
+		encoder = newConsoleEncoder(encoderConfig)
+	}
+	core := zapcore.NewCore(encoder, zapcore.Lock(os.Stderr), level)
+	return zap.New(core, options...)
+}
+
+func newOutputLogger() *zap.Logger {
+	// Return a logger that produces expected structured output format for output
+	options := []zap.Option{
+		zap.Fields(
+			// Message format version
+			zap.String("version", "v1.0.0"),
+		),
+	}
+
+	level := zap.NewAtomicLevelAt(zap.DebugLevel)
+	options = append(options, zap.AddStacktrace(zap.WarnLevel))
+
+	encoderConfig := zapcore.EncoderConfig{
+		TimeKey:       "timestamp",
+		LevelKey:      "",
+		MessageKey:    "body",
+		StacktraceKey: "",
+		EncodeLevel:   zapcore.LowercaseLevelEncoder,
+		EncodeTime:    zapcore.RFC3339NanoTimeEncoder,
+	}
+	encoder := zapcore.NewJSONEncoder(encoderConfig)
+	core := zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level)
+	return zap.New(core, options...)
+}
diff --git a/cmd/root.go b/cmd/root.go
index 8048073b..40320884 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -15,16 +15,14 @@
 package cmd
 
 import (
-	"io/ioutil"
-	"log"
+	"errors"
+	"fmt"
 	"os"
 
 	"github.com/spf13/cobra"
+	"go.uber.org/zap"
 )
 
-// LoggerStdErr used by commands to print errors and warnings
-var LoggerStdErr = log.New(os.Stderr, "", log.LstdFlags)
-
 const rootCmdDesc = `
 Validate that a terraform plan conforms to a Constraint Framework 
 policy library written to expect Google CAI (Cloud Asset Inventory) data.
@@ -32,41 +30,58 @@ policy library written to expect Google CAI (Cloud Asset Inventory) data.
 Supported Terraform versions = 0.12+`
 
 type rootOptions struct {
-	verbose bool
+	verbose     bool
+	errorLogger *zap.Logger
 }
 
-func newRootCmd() *cobra.Command {
+func newRootCmd() (*cobra.Command, *zap.Logger, error) {
 	o := &rootOptions{}
 
 	cmd := &cobra.Command{
-		Use:   "terraform-validator",
-		Short: "Validate that a terraform plan conforms to Constraint Framework policies",
-		Long: rootCmdDesc,
-		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
-			if !o.verbose {
-				// Suppress chatty packages.
-				log.SetOutput(ioutil.Discard)
-			}
-			return nil
-		},
+		Use:           "terraform-validator",
+		Short:         "Validate that a terraform plan conforms to Constraint Framework policies",
+		Long:          rootCmdDesc,
+		SilenceUsage:  true,
+		SilenceErrors: true,
 	}
 
-	cmd.PersistentFlags().BoolVar(&o.verbose, "verbose", false, "Log output to stderr")
+	cmd.PersistentFlags().BoolVar(&o.verbose, "verbose", false, "Log additional output")
+
+	useStructuredLogging := os.Getenv("USE_STRUCTURED_LOGGING") == "true"
+	errorLogger := newErrorLogger(o.verbose, useStructuredLogging)
+	defer errorLogger.Sync()
+	zap.RedirectStdLog(errorLogger)
+	o.errorLogger = errorLogger
 
-	cmd.AddCommand(newConvertCmd())
+	outputLogger := newOutputLogger()
+	defer outputLogger.Sync()
+
+	cmd.AddCommand(newConvertCmd(errorLogger, outputLogger, useStructuredLogging))
 	cmd.AddCommand(newListSupportedResourcesCmd())
-	cmd.AddCommand(newValidateCmd())
+	cmd.AddCommand(newValidateCmd(errorLogger, outputLogger, useStructuredLogging))
 	cmd.AddCommand(newVersionCmd())
 
-	return cmd
+	return cmd, errorLogger, nil
 }
 
 // Execute is the entry-point for all commands.
 // This lets us keep all new command functions private.
 func Execute() {
-	rootCmd := newRootCmd()
+	rootCmd, logger, err := newRootCmd()
+
+	if err != nil {
+		fmt.Printf("Error creating root logger: %s", err)
+		os.Exit(1)
+	}
+
+	err = rootCmd.Execute()
 
-	if err := rootCmd.Execute(); err != nil {
+	if err == nil {
+		os.Exit(0)
+	} else if errors.Is(err, errViolations) {
+		os.Exit(2)
+	} else {
+		logger.Error(err.Error())
 		os.Exit(1)
 	}
 }
diff --git a/cmd/validate.go b/cmd/validate.go
index 05e3ea13..5d2caca8 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -23,6 +23,7 @@ import (
 	"github.com/golang/protobuf/jsonpb"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
+	"go.uber.org/zap"
 )
 
 const validateDesc = `
@@ -41,23 +42,32 @@ Example:
 `
 
 type validateOptions struct {
-	project    string
-	ancestry   string
-	offline    bool
-	policyPath string
-	outputJSON bool
-	dryRun     bool
+	project              string
+	ancestry             string
+	offline              bool
+	policyPath           string
+	outputJSON           bool
+	dryRun               bool
+	errorLogger          *zap.Logger
+	outputLogger         *zap.Logger
+	useStructuredLogging bool
+	readPlannedAssets    tfgcv.ReadPlannedAssetsFunc
+	validateAssets       tfgcv.ValidateAssetsFunc
 }
 
-
-func newValidateCmd() *cobra.Command {
-	o := &validateOptions{}
+func newValidateCmd(errorLogger, outputLogger *zap.Logger, useStructuredLogging bool) *cobra.Command {
+	o := &validateOptions{
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    tfgcv.ReadPlannedAssets,
+		validateAssets:       tfgcv.ValidateAssets,
+	}
 
 	cmd := &cobra.Command{
 		Use:   "validate TFPLAN_JSON --policy-path=/path/to/policy/library",
 		Short: "Validate that a terraform plan conforms to Constraint Framework policies",
-		Long: validateDesc,
-		SilenceUsage: os.Getenv("COBRA_SILENCE_USAGE") == "true",
+		Long:  validateDesc,
 		PreRunE: func(c *cobra.Command, args []string) error {
 			return o.validateArgs(args)
 		},
@@ -93,7 +103,7 @@ func (o *validateOptions) validateArgs(args []string) error {
 
 func (o *validateOptions) run(plan string) error {
 	ctx := context.Background()
-	assets, err := tfgcv.ReadPlannedAssets(ctx, plan, o.project, o.ancestry, o.offline, false)
+	assets, err := o.readPlannedAssets(ctx, plan, o.project, o.ancestry, o.offline, false)
 	if err != nil {
 		if errors.Cause(err) == tfgcv.ErrParsingProviderProject {
 			return errors.New("unable to parse provider project, please use --project flag")
@@ -101,11 +111,27 @@ func (o *validateOptions) run(plan string) error {
 		return errors.Wrap(err, "converting tfplan to CAI assets")
 	}
 
-	auditResult, err := tfgcv.ValidateAssets(ctx, assets, o.policyPath)
+	auditResult, err := o.validateAssets(ctx, assets, o.policyPath)
 	if err != nil {
 		return errors.Wrap(err, "validating: FCV")
 	}
 
+	if o.useStructuredLogging {
+		msg := "No violations found"
+		if len(auditResult.Violations) > 0 {
+			msg = "Violations found"
+		}
+		o.outputLogger.Info(
+			msg,
+			zap.Any("resource_body", auditResult.Violations),
+		)
+		if len(auditResult.Violations) > 0 {
+			return errViolations
+		}
+		return nil
+	}
+
+	// Legacy behavior
 	if len(auditResult.Violations) > 0 {
 		if o.outputJSON {
 			marshaller := &jsonpb.Marshaler{}
@@ -123,7 +149,7 @@ func (o *validateOptions) run(plan string) error {
 			}
 		}
 
-		os.Exit(2)
+		return errViolations
 	}
 
 	if !o.outputJSON {
diff --git a/cmd/version.go b/cmd/version.go
index 6492771b..21efe635 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -16,14 +16,13 @@ package cmd
 
 import (
 	"fmt"
-	"os"
 
 	"github.com/GoogleCloudPlatform/terraform-validator/tfgcv"
 
 	"github.com/spf13/cobra"
 )
 
-type versionOptions struct {}
+type versionOptions struct{}
 
 func newVersionCmd() *cobra.Command {
 	o := versionOptions{}
@@ -31,7 +30,6 @@ func newVersionCmd() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "version",
 		Short: "Display Terraform Validator version.",
-		SilenceUsage: os.Getenv("COBRA_SILENCE_USAGE") == "true",
 		RunE: func(c *cobra.Command, args []string) error {
 			return o.run()
 		},
diff --git a/go.mod b/go.mod
index 31ceca2d..74e86962 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,9 @@ require (
 	github.com/pkg/errors v0.9.1
 	github.com/spf13/cobra v1.1.3
 	github.com/stretchr/testify v1.7.0
+	go.uber.org/atomic v1.9.0 // indirect
+	go.uber.org/multierr v1.7.0 // indirect
+	go.uber.org/zap v1.19.1 // indirect
 	google.golang.org/api v0.46.0
 	google.golang.org/genproto v0.0.0-20210503173045-b96a97608f20
 )
diff --git a/go.sum b/go.sum
index 2925df7e..700d6dc5 100644
--- a/go.sum
+++ b/go.sum
@@ -155,6 +155,7 @@ github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU
 github.com/aws/aws-sdk-go v1.36.30 h1:hAwyfe7eZa7sM+S5mIJZFiNFwJMia9Whz6CYblioLoU=
 github.com/aws/aws-sdk-go v1.36.30/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -1130,12 +1131,19 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q=
+go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
 go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=
 go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
 go.uber.org/zap v0.0.0-20180814183419-67bc79d13d15/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
@@ -1143,6 +1151,8 @@ go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU=
 go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
+go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI=
+go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
 golang.org/x/crypto v0.0.0-20180501155221-613d6eafa307/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -1504,6 +1514,7 @@ golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
 golang.org/x/tools v0.1.2-0.20210512205948-8287d5da45e4 h1:cYSqdOzmV9wJ7lWurRAws06Dmif0Wv6UL4gQLlz+im0=
 golang.org/x/tools v0.1.2-0.20210512205948-8287d5da45e4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1685,6 +1696,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 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=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/tfgcv/planned_assets.go b/tfgcv/planned_assets.go
index 31d9b868..69bc8a9d 100644
--- a/tfgcv/planned_assets.go
+++ b/tfgcv/planned_assets.go
@@ -27,6 +27,8 @@ import (
 	"github.com/pkg/errors"
 )
 
+type ReadPlannedAssetsFunc func(ctx context.Context, path, project, ancestry string, offline, convertUnchanged bool) ([]google.Asset, error)
+
 // ReadPlannedAssets extracts CAI assets from a terraform plan file.
 // If ancestry path is provided, it assumes the project is in that path rather
 // than fetching the ancestry information using Google API. If convertUnchanged
diff --git a/tfgcv/validate_assets.go b/tfgcv/validate_assets.go
index b71fae76..3be8d8d5 100644
--- a/tfgcv/validate_assets.go
+++ b/tfgcv/validate_assets.go
@@ -32,6 +32,8 @@ func BuildVersion() string {
 	return buildVersion
 }
 
+type ValidateAssetsFunc func(ctx context.Context, assets []google.Asset, policyRootPath string) (*validator.AuditResponse, error)
+
 // ValidateAssets instantiates GCV and audits CAI assets using "policies"
 // and "lib" folder under policyRootPath.
 func ValidateAssets(ctx context.Context, assets []google.Asset, policyRootPath string) (*validator.AuditResponse, error) {


Test patch:
diff --git a/cmd/convert_test.go b/cmd/convert_test.go
new file mode 100644
index 00000000..47ea785b
--- /dev/null
+++ b/cmd/convert_test.go
@@ -0,0 +1,92 @@
+package cmd
+
+import (
+	"context"
+	"testing"
+
+	"github.com/GoogleCloudPlatform/terraform-validator/converters/google"
+	"github.com/stretchr/testify/assert"
+)
+
+func MockReadPlannedAssets(ctx context.Context, path, project, ancestry string, offline, convertUnchanged bool) ([]google.Asset, error) {
+	return []google.Asset{
+		google.Asset{
+			Name: "//compute.googleapis.com/projects/my-project/zones/us-central1-a/disks/test-disk",
+			Type: "compute.googleapis.com/Disk",
+			Resource: &google.AssetResource{
+				Version:              "v1",
+				DiscoveryDocumentURI: "https://www.googleapis.com/discovery/v1/apis/compute/v1/rest",
+				DiscoveryName:        "Disk",
+				Data: map[string]interface{}{
+					"labels": map[string]string{
+						"environment": "dev",
+					},
+					"name":                   "test-disk",
+					"physicalBlockSizeBytes": 4096,
+					"sourceImage":            "projects/debian-cloud/global/images/debian-8-jessie-v20170523",
+					"type":                   "projects/my-project/zones/us-central1-a/diskTypes/pd-ssd",
+					"zone":                   "projects/my-project/global/zones/us-central1-a",
+				},
+			},
+		},
+	}, nil
+}
+
+func TestConvertRun(t *testing.T) {
+	a := assert.New(t)
+	verbose := true
+	useStructuredLogging := true
+	errorLogger, errorObservedLogs := newTestErrorLogger(verbose, useStructuredLogging)
+	outputLogger, outputObservedLogs := newTestOutputLogger()
+	o := convertOptions{
+		project:              "",
+		ancestry:             "",
+		offline:              false,
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    MockReadPlannedAssets,
+	}
+
+	err := o.run("/path/to/plan")
+	a.Nil(err)
+
+	errorLogs := errorObservedLogs.AllUntimed()
+	outputLogs := outputObservedLogs.AllUntimed()
+
+	a.Len(errorLogs, 0)
+	a.Len(outputLogs, 1)
+
+	// On a successful run, we should see a list of google assets in the resource_body field
+	fields := outputLogs[0].ContextMap()
+	a.Contains(fields, "resource_body")
+	a.Len(fields["resource_body"], 1)
+	a.IsType([]google.Asset{}, fields["resource_body"])
+}
+
+func TestConvertRunLegacy(t *testing.T) {
+	a := assert.New(t)
+	verbose := true
+	useStructuredLogging := false
+	errorLogger, errorObservedLogs := newTestErrorLogger(verbose, useStructuredLogging)
+	outputLogger, outputObservedLogs := newTestOutputLogger()
+	o := convertOptions{
+		project:              "",
+		ancestry:             "",
+		offline:              false,
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    MockReadPlannedAssets,
+	}
+
+	err := o.run("/path/to/plan")
+	a.Nil(err)
+
+	errorLogs := errorObservedLogs.AllUntimed()
+	outputLogs := outputObservedLogs.AllUntimed()
+
+	// On a successful legacy run, we don't output anything via loggers.
+	a.Len(errorLogs, 0)
+	a.Len(outputLogs, 0)
+}
diff --git a/cmd/logger_test.go b/cmd/logger_test.go
new file mode 100644
index 00000000..0bf9c224
--- /dev/null
+++ b/cmd/logger_test.go
@@ -0,0 +1,27 @@
+package cmd
+
+import (
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+	"go.uber.org/zap/zaptest/observer"
+)
+
+func newTestErrorLogger(verbose, useStructuredLogging bool) (*zap.Logger, *observer.ObservedLogs) {
+	logger := newErrorLogger(verbose, useStructuredLogging)
+	observerCore, logs := observer.New(zap.DebugLevel)
+	wrapper := zap.WrapCore(func(zapcore.Core) zapcore.Core {
+		return observerCore
+	})
+
+	return logger.WithOptions(wrapper), logs
+}
+
+func newTestOutputLogger() (*zap.Logger, *observer.ObservedLogs) {
+	logger := newOutputLogger()
+	observerCore, logs := observer.New(zap.DebugLevel)
+	wrapper := zap.WrapCore(func(zapcore.Core) zapcore.Core {
+		return observerCore
+	})
+
+	return logger.WithOptions(wrapper), logs
+}
diff --git a/cmd/validate_test.go b/cmd/validate_test.go
new file mode 100644
index 00000000..46176f22
--- /dev/null
+++ b/cmd/validate_test.go
@@ -0,0 +1,163 @@
+package cmd
+
+import (
+	"context"
+	"testing"
+
+	"github.com/GoogleCloudPlatform/terraform-validator/converters/google"
+	"github.com/forseti-security/config-validator/pkg/api/validator"
+	"github.com/stretchr/testify/assert"
+)
+
+func MockValidateAssetsNoViolations(ctx context.Context, assets []google.Asset, policyRootPath string) (*validator.AuditResponse, error) {
+	return &validator.AuditResponse{
+		Violations: []*validator.Violation{},
+	}, nil
+}
+
+func MockValidateAssetsWithViolations(ctx context.Context, assets []google.Asset, policyRootPath string) (*validator.AuditResponse, error) {
+	return &validator.AuditResponse{
+		Violations: []*validator.Violation{
+			&validator.Violation{
+				Constraint: "GCPAlwaysViolatesConstraintV1.always_violates_project_match_target",
+				Resource:   "//bigtable.googleapis.com/projects/my-project/instances/tf-instance",
+				Message:    "Constraint GCPAlwaysViolatesConstraintV1.always_violates_project_match_target on resource //bigtable.googleapis.com/projects/my-project/instances/tf-instance",
+				Severity:   "high",
+			},
+		},
+	}, nil
+}
+
+func TestValidateRun(t *testing.T) {
+	a := assert.New(t)
+	verbose := true
+	useStructuredLogging := true
+	errorLogger, errorObservedLogs := newTestErrorLogger(verbose, useStructuredLogging)
+	outputLogger, outputObservedLogs := newTestOutputLogger()
+	o := validateOptions{
+		project:              "",
+		ancestry:             "",
+		offline:              false,
+		policyPath:           "",
+		outputJSON:           false,
+		dryRun:               false,
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    MockReadPlannedAssets,
+		validateAssets:       MockValidateAssetsWithViolations,
+	}
+
+	err := o.run("/path/to/plan")
+	a.ErrorIs(err, errViolations)
+
+	errorLogs := errorObservedLogs.AllUntimed()
+	outputLogs := outputObservedLogs.AllUntimed()
+
+	a.Len(errorLogs, 0)
+	a.Len(outputLogs, 1)
+
+	// On a run with violations, we should see a list of violations in the resource_body field
+	fields := outputLogs[0].ContextMap()
+	a.Contains(fields, "resource_body")
+	a.Len(fields["resource_body"], 1)
+	a.IsType([]*validator.Violation{}, fields["resource_body"])
+}
+
+func TestValidateRunNoViolations(t *testing.T) {
+	a := assert.New(t)
+	verbose := true
+	useStructuredLogging := true
+	errorLogger, errorObservedLogs := newTestErrorLogger(verbose, useStructuredLogging)
+	outputLogger, outputObservedLogs := newTestOutputLogger()
+	o := validateOptions{
+		project:              "",
+		ancestry:             "",
+		offline:              false,
+		policyPath:           "",
+		outputJSON:           false,
+		dryRun:               false,
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    MockReadPlannedAssets,
+		validateAssets:       MockValidateAssetsNoViolations,
+	}
+
+	err := o.run("/path/to/plan")
+	a.Nil(err)
+
+	errorLogs := errorObservedLogs.AllUntimed()
+	outputLogs := outputObservedLogs.AllUntimed()
+
+	a.Len(errorLogs, 0)
+	a.Len(outputLogs, 1)
+
+	// On a run with no violations, we should see an empty list of violations in the resource_body field
+	fields := outputLogs[0].ContextMap()
+	a.Contains(fields, "resource_body")
+	a.Len(fields["resource_body"], 0)
+	a.IsType([]*validator.Violation{}, fields["resource_body"])
+}
+
+func TestValidateRunLegacy(t *testing.T) {
+	a := assert.New(t)
+	verbose := true
+	useStructuredLogging := false
+	errorLogger, errorObservedLogs := newTestErrorLogger(verbose, useStructuredLogging)
+	outputLogger, outputObservedLogs := newTestOutputLogger()
+	o := validateOptions{
+		project:              "",
+		ancestry:             "",
+		offline:              false,
+		policyPath:           "",
+		outputJSON:           false,
+		dryRun:               false,
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    MockReadPlannedAssets,
+		validateAssets:       MockValidateAssetsWithViolations,
+	}
+
+	err := o.run("/path/to/plan")
+	a.ErrorIs(err, errViolations)
+
+	errorLogs := errorObservedLogs.AllUntimed()
+	outputLogs := outputObservedLogs.AllUntimed()
+
+	// On a legacy run with validation errors, loggers should not be used.
+	a.Len(errorLogs, 0)
+	a.Len(outputLogs, 0)
+}
+
+func TestValidateRunNoViolationsLegacy(t *testing.T) {
+	a := assert.New(t)
+	verbose := true
+	useStructuredLogging := false
+	errorLogger, errorObservedLogs := newTestErrorLogger(verbose, useStructuredLogging)
+	outputLogger, outputObservedLogs := newTestOutputLogger()
+	o := validateOptions{
+		project:              "",
+		ancestry:             "",
+		offline:              false,
+		policyPath:           "",
+		outputJSON:           false,
+		dryRun:               false,
+		errorLogger:          errorLogger,
+		outputLogger:         outputLogger,
+		useStructuredLogging: useStructuredLogging,
+		readPlannedAssets:    MockReadPlannedAssets,
+		validateAssets:       MockValidateAssetsNoViolations,
+	}
+
+	err := o.run("/path/to/plan")
+	a.Nil(err)
+
+	errorLogs := errorObservedLogs.AllUntimed()
+	outputLogs := outputObservedLogs.AllUntimed()
+
+	// On a legacy run with no validation errors, loggers should not be used.
+	a.Len(errorLogs, 0)
+	a.Len(outputLogs, 0)
+}