From 3f83e9bb1c5eb981bda43c078f166125b0bbc8f5 Mon Sep 17 00:00:00 2001 From: wata_mac Date: Fri, 4 Mar 2022 00:18:37 +0900 Subject: [PATCH] Add tests and docs --- logger/doc.go | 7 + plugin/fromproto/doc.go | 12 + plugin/host2plugin/doc.go | 12 + plugin/interceptor/doc.go | 4 + plugin/plugin2host/doc.go | 12 + plugin/plugin2host/plugin2host_test.go | 1239 ++++++++++++++++++++++++ plugin/proto/doc.go | 6 + plugin/toproto/doc.go | 9 + 8 files changed, 1301 insertions(+) create mode 100644 logger/doc.go create mode 100644 plugin/fromproto/doc.go create mode 100644 plugin/host2plugin/doc.go create mode 100644 plugin/interceptor/doc.go create mode 100644 plugin/plugin2host/doc.go create mode 100644 plugin/plugin2host/plugin2host_test.go create mode 100644 plugin/proto/doc.go create mode 100644 plugin/toproto/doc.go diff --git a/logger/doc.go b/logger/doc.go new file mode 100644 index 0000000..90b5669 --- /dev/null +++ b/logger/doc.go @@ -0,0 +1,7 @@ +// Package logger provides a global logger interface for logging from plugins. +// +// This package is a wrapper for hclog, and it initializes the global logger on import. +// You can freely write logs from anywhere via the public API according to the log level. +// The log by hclog is interpreted as a structured log by go-plugin, and the log level +// can be handled correctly. +package logger diff --git a/plugin/fromproto/doc.go b/plugin/fromproto/doc.go new file mode 100644 index 0000000..1ec9df5 --- /dev/null +++ b/plugin/fromproto/doc.go @@ -0,0 +1,12 @@ +// Package fromproto contains an implementation to decode a structure +// generated from *.proto into a real Go structure. This package is not +// intended to be used directly from plugins. +// +// Many primitives can be handled as-is, but some interfaces and errors +// require special decoding. The `hcl.Expression` restores the interface +// by reparsed based on the bytes and their range. The `tflint.Rule` +// restores the interface by filling the value in a pseudo-structure that +// satisfies the interface. Error makes use of gRPC error details to recover +// the wrapped error. Rewrap the error based on the error code obtained +// from details. +package fromproto diff --git a/plugin/host2plugin/doc.go b/plugin/host2plugin/doc.go new file mode 100644 index 0000000..4f67c4a --- /dev/null +++ b/plugin/host2plugin/doc.go @@ -0,0 +1,12 @@ +// Package host2pluign contains a gRPC server (plugin) and client (host). +// +// In the plugin system, this communication is the first thing that happens, +// and a plugin must use this package to provide a gRPC server. +// However, the detailed implementation is hidden in the tflint.RuleSet interface, +// and plugin developers usually don't need to be aware of gRPC server behavior. +// +// When the host initializes a gRPC client, go-plugin starts a gRPC server +// on the plugin side as another process. This package acts as a wrapper for go-plugin. +// Separately, the Check function initializes a new gRPC client for plugin-to-host +// communication. See the plugin2host package for details. +package host2plugin diff --git a/plugin/interceptor/doc.go b/plugin/interceptor/doc.go new file mode 100644 index 0000000..065fdeb --- /dev/null +++ b/plugin/interceptor/doc.go @@ -0,0 +1,4 @@ +// Package interceptor contains gRPC interceptors. +// This package is not intended to be used directly from plugins. +// Its main use today is to insert shared processes such as logging. +package interceptor diff --git a/plugin/plugin2host/doc.go b/plugin/plugin2host/doc.go new file mode 100644 index 0000000..672c5e0 --- /dev/null +++ b/plugin/plugin2host/doc.go @@ -0,0 +1,12 @@ +// Package plugin2host contains a gRPC server (host) and client (plugin). +// +// Communication from the plugin to the host is the second one that occurs. +// To understand what happens first, see the host2plugin package first. +// The gRPC client used by the plugin is implicitly initialized by the host2plugin +// package and hidden in the tflint.Runner interface. Normally, plugin developers +// do not need to be aware of the details of this client. +// +// The host starts a gRPC server as goroutine to respond from the plugin side +// when calling Check function in host2plugin. Please note that the gRPC server +// and client startup in plugin2host is not due to go-plugin. +package plugin2host diff --git a/plugin/plugin2host/plugin2host_test.go b/plugin/plugin2host/plugin2host_test.go new file mode 100644 index 0000000..8b434e1 --- /dev/null +++ b/plugin/plugin2host/plugin2host_test.go @@ -0,0 +1,1239 @@ +package plugin2host + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/plugin/proto" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" + "github.com/zclconf/go-cty/cty" + "google.golang.org/grpc" +) + +func startTestGRPCServer(t *testing.T, runner Server) *GRPCClient { + conn, _ := plugin.TestGRPCConn(t, func(server *grpc.Server) { + proto.RegisterRunnerServer(server, &GRPCServer{Impl: runner}) + }) + + return &GRPCClient{Client: proto.NewRunnerClient(conn)} +} + +var _ Server = &mockServer{} + +type mockServer struct { + impl mockServerImpl +} + +type mockServerImpl struct { + getModuleContent func(*hclext.BodySchema, tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) + getFile func(string) (*hcl.File, error) + getRuleConfigContent func(string, *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) + evaluateExpr func(hcl.Expression, tflint.EvaluateExprOption) (cty.Value, error) + emitIssue func(tflint.Rule, string, hcl.Range) error + getFiles func() map[string]*hcl.File +} + +func newMockServer(impl mockServerImpl) *mockServer { + return &mockServer{impl: impl} +} + +func (s *mockServer) GetModuleContent(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + if s.impl.getModuleContent != nil { + return s.impl.getModuleContent(schema, opts) + } + return &hclext.BodyContent{}, hcl.Diagnostics{} +} + +func (s *mockServer) GetFile(filename string) (*hcl.File, error) { + if s.impl.getFile != nil { + return s.impl.getFile(filename) + } + return nil, nil +} + +func (s *mockServer) GetRuleConfigContent(name string, schema *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) { + if s.impl.getRuleConfigContent != nil { + return s.impl.getRuleConfigContent(name, schema) + } + return &hclext.BodyContent{}, &hcl.File{}, nil +} + +func (s *mockServer) EvaluateExpr(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if s.impl.evaluateExpr != nil { + return s.impl.evaluateExpr(expr, opts) + } + return cty.Value{}, nil +} + +func (s *mockServer) EmitIssue(rule tflint.Rule, message string, location hcl.Range) error { + if s.impl.emitIssue != nil { + return s.impl.emitIssue(rule, message, location) + } + return nil +} + +func (s *mockServer) GetFiles(tflint.ModuleCtxType) map[string]*hcl.File { + if s.impl.getFiles != nil { + return s.impl.getFiles() + } + return map[string]*hcl.File{} +} + +// @see https://github.com/google/go-cmp/issues/40 +var allowAllUnexported = cmp.Exporter(func(reflect.Type) bool { return true }) + +func TestGetResourceContent(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + // default getFileImpl function + files := map[string]*hcl.File{} + fileExists := func() map[string]*hcl.File { + return files + } + + // test util functions + hclFile := func(filename string, code string) *hcl.File { + file, diags := hclsyntax.ParseConfig([]byte(code), filename, hcl.InitialPos) + if diags.HasErrors() { + panic(diags) + } + files[filename] = file + return file + } + jsonFile := func(filename string, code string) *hcl.File { + file, diags := json.Parse([]byte(code), filename) + if diags.HasErrors() { + panic(diags) + } + files[filename] = file + return file + } + + tests := []struct { + Name string + Args func() (string, *hclext.BodySchema, *tflint.GetModuleContentOption) + ServerImpl func(*hclext.BodySchema, tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) + Want func(string, *hclext.BodySchema, *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) + ErrCheck func(error) bool + }{ + { + Name: "get HCL content", + Args: func() (string, *hclext.BodySchema, *tflint.GetModuleContentOption) { + return "aws_instance", &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, nil + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + file := hclFile("test.tf", ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +} + +resource "aws_s3_bucket" "bar" { + bucket = "test" +}`) + return hclext.PartialContent(file.Body, schema) + }, + Want: func(resource string, schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + // Removed "aws_s3_bucket" resource + file := hclFile("test.tf", ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`) + return hclext.Content(file.Body, &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { + Type: "resource", + LabelNames: []string{"type", "name"}, + Body: &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, + }, + }, + }) + }, + ErrCheck: neverHappend, + }, + { + Name: "get JSON content", + Args: func() (string, *hclext.BodySchema, *tflint.GetModuleContentOption) { + return "aws_instance", &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, nil + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + file := jsonFile("test.tf.json", ` +{ + "resource": { + "aws_instance": { + "foo": { + "instance_type": "t2.micro" + } + }, + "aws_s3_bucket": { + "bar": { + "bucket": "test" + } + } + } +}`) + return hclext.PartialContent(file.Body, schema) + }, + Want: func(resource string, schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + // Removed "aws_s3_bucket" resource + file := jsonFile("test.tf.json", ` +{ + "resource": { + "aws_instance": { + "foo": { + "instance_type": "t2.micro" + } + } + } +}`) + return hclext.Content(file.Body, &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { + Type: "resource", + LabelNames: []string{"type", "name"}, + Body: &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, + }, + }, + }) + }, + ErrCheck: neverHappend, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + client := startTestGRPCServer(t, newMockServer(mockServerImpl{getModuleContent: test.ServerImpl, getFiles: fileExists})) + + got, err := client.GetResourceContent(test.Args()) + if test.ErrCheck(err) { + t.Fatalf("failed to call GetResourceContent: %s", err) + } + want, diags := test.Want(test.Args()) + if diags.HasErrors() { + t.Fatalf("failed to get want: %d diagsnotics", len(diags)) + for _, diag := range diags { + t.Logf(" - %s", diag.Error()) + } + } + + opts := cmp.Options{ + cmp.Comparer(func(x, y cty.Value) bool { + return x.GoString() == y.GoString() + }), + cmpopts.EquateEmpty(), + allowAllUnexported, + } + if diff := cmp.Diff(got, want, opts); diff != "" { + t.Errorf("diff: %s", diff) + } + }) + } +} + +func TestGetModuleContent(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + // default getFileImpl function + files := map[string]*hcl.File{} + fileExists := func() map[string]*hcl.File { + return files + } + + // test util functions + hclFile := func(filename string, code string) *hcl.File { + file, diags := hclsyntax.ParseConfig([]byte(code), filename, hcl.InitialPos) + if diags.HasErrors() { + panic(diags) + } + files[filename] = file + return file + } + jsonFile := func(filename string, code string) *hcl.File { + file, diags := json.Parse([]byte(code), filename) + if diags.HasErrors() { + panic(diags) + } + files[filename] = file + return file + } + + tests := []struct { + Name string + Args func() (*hclext.BodySchema, *tflint.GetModuleContentOption) + ServerImpl func(*hclext.BodySchema, tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) + Want func(*hclext.BodySchema, *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) + ErrCheck func(error) bool + }{ + { + Name: "get HCL content", + Args: func() (*hclext.BodySchema, *tflint.GetModuleContentOption) { + return &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { + Type: "resource", + LabelNames: []string{"type", "name"}, + Body: &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, + }, + }, + }, nil + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + file := hclFile("test.tf", ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`) + return hclext.Content(file.Body, schema) + }, + Want: func(schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + file := hclFile("test.tf", ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`) + return hclext.Content(file.Body, schema) + }, + ErrCheck: neverHappend, + }, + { + Name: "get JSON content", + Args: func() (*hclext.BodySchema, *tflint.GetModuleContentOption) { + return &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { + Type: "resource", + LabelNames: []string{"type", "name"}, + Body: &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, + }, + }, + }, + }, nil + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + file := jsonFile("test.tf.json", ` +{ + "resource": { + "aws_instance": { + "foo": { + "instance_type": "t2.micro" + } + } + } +}`) + return hclext.Content(file.Body, schema) + }, + Want: func(schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + file := jsonFile("test.tf.json", ` +{ + "resource": { + "aws_instance": { + "foo": { + "instance_type": "t2.micro" + } + } + } +}`) + return hclext.Content(file.Body, schema) + }, + ErrCheck: neverHappend, + }, + { + Name: "get content with options", + Args: func() (*hclext.BodySchema, *tflint.GetModuleContentOption) { + return &hclext.BodySchema{}, &tflint.GetModuleContentOption{ModuleCtx: tflint.RootModuleCtxType} + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + if opts.ModuleCtx != tflint.RootModuleCtxType { + return &hclext.BodyContent{}, hcl.Diagnostics{ + &hcl.Diagnostic{Severity: hcl.DiagError, Summary: "unexpected options"}, + } + } + return &hclext.BodyContent{}, hcl.Diagnostics{} + }, + Want: func(schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + return &hclext.BodyContent{ + Attributes: hclext.Attributes{}, + Blocks: hclext.Blocks{}, + }, hcl.Diagnostics{} + }, + ErrCheck: neverHappend, + }, + { + Name: "schema is null", + Args: func() (*hclext.BodySchema, *tflint.GetModuleContentOption) { + return nil, nil + }, + Want: func(schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + return &hclext.BodyContent{ + Attributes: hclext.Attributes{}, + Blocks: hclext.Blocks{}, + }, hcl.Diagnostics{} + }, + ErrCheck: neverHappend, + }, + { + Name: "server returns an error", + Args: func() (*hclext.BodySchema, *tflint.GetModuleContentOption) { + return &hclext.BodySchema{}, nil + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + return &hclext.BodyContent{}, hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "unexpected error", + Detail: "unexpected error occurred", + Subject: &hcl.Range{Filename: "test.tf", Start: hcl.Pos{Line: 1, Column: 1}, End: hcl.Pos{Line: 1, Column: 5}}, + }, + } + }, + Want: func(schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + return nil, hcl.Diagnostics{} + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "test.tf:1,1-5: unexpected error; unexpected error occurred" + }, + }, + { + Name: "response body is empty", + Args: func() (*hclext.BodySchema, *tflint.GetModuleContentOption) { + return &hclext.BodySchema{}, nil + }, + ServerImpl: func(schema *hclext.BodySchema, opts tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + return nil, hcl.Diagnostics{} + }, + Want: func(schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, hcl.Diagnostics) { + return nil, hcl.Diagnostics{} + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "response body is empty" + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + client := startTestGRPCServer(t, newMockServer(mockServerImpl{getModuleContent: test.ServerImpl, getFiles: fileExists})) + + got, err := client.GetModuleContent(test.Args()) + if test.ErrCheck(err) { + t.Fatalf("failed to call GetModuleContent: %s", err) + } + want, diags := test.Want(test.Args()) + if diags.HasErrors() { + t.Fatalf("failed to get want: %d diagsnotics", len(diags)) + for _, diag := range diags { + t.Logf(" - %s", diag.Error()) + } + } + + opts := cmp.Options{ + cmp.Comparer(func(x, y cty.Value) bool { + return x.GoString() == y.GoString() + }), + allowAllUnexported, + } + if diff := cmp.Diff(got, want, opts); diff != "" { + t.Errorf("diff: %s", diff) + } + }) + } +} + +func TestGetFile(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + // test util functions + hclFile := func(filename string, code string) (*hcl.File, error) { + file, diags := hclsyntax.ParseConfig([]byte(code), filename, hcl.InitialPos) + if diags.HasErrors() { + return nil, diags + } + return file, nil + } + jsonFile := func(filename string, code string) (*hcl.File, error) { + file, diags := json.Parse([]byte(code), filename) + if diags.HasErrors() { + return nil, diags + } + return file, nil + } + + tests := []struct { + Name string + Arg string + ServerImpl func(string) (*hcl.File, error) + Want string + ErrCheck func(error) bool + }{ + { + Name: "HCL file exists", + Arg: "test.tf", + ServerImpl: func(filename string) (*hcl.File, error) { + if filename != "test.tf" { + return nil, nil + } + return hclFile(filename, ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`) + }, + Want: ` +resource "aws_instance" "foo" { + instance_type = "t2.micro" +}`, + ErrCheck: neverHappend, + }, + { + Name: "JSON file exists", + Arg: "test.tf.json", + ServerImpl: func(filename string) (*hcl.File, error) { + if filename != "test.tf.json" { + return nil, nil + } + return jsonFile(filename, ` +{ + "resource": { + "aws_instance": { + "foo": { + "instance_type": "t2.micro" + } + } + } +}`) + }, + Want: ` +{ + "resource": { + "aws_instance": { + "foo": { + "instance_type": "t2.micro" + } + } + } +}`, + ErrCheck: neverHappend, + }, + { + Name: "file not found", + Arg: "test.tf", + ServerImpl: func(filename string) (*hcl.File, error) { + return nil, nil + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "file not found" + }, + }, + { + Name: "server returns an error", + Arg: "test.tf", + ServerImpl: func(filename string) (*hcl.File, error) { + if filename != "test.tf" { + return nil, nil + } + return nil, errors.New("unexpected error") + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "unexpected error" + }, + }, + { + Name: "filename is empty", + Arg: "", + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "name should not be empty" + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + client := startTestGRPCServer(t, newMockServer(mockServerImpl{getFile: test.ServerImpl})) + + file, err := client.GetFile(test.Arg) + if test.ErrCheck(err) { + t.Fatalf("failed to call GetFile: %s", err) + } + + var got string + if file != nil { + got = string(file.Bytes) + } + + if got != test.Want { + t.Errorf("got: %s", got) + } + }) + } +} + +func TestDecodeRuleConfig(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + // test struct for decoding + type ruleConfig struct { + Name string `hclext:"name"` + } + + tests := []struct { + Name string + RuleName string + Target interface{} + ServerImpl func(string, *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) + Want interface{} + ErrCheck func(error) bool + }{ + { + Name: "decode to struct", + RuleName: "test_rule", + Target: &ruleConfig{}, + ServerImpl: func(name string, schema *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) { + if name != "test_rule" { + return &hclext.BodyContent{}, &hcl.File{}, errors.New("unexpected file name") + } + + // Should return code inside of "rule" block + // + // rule "test_rule" { + // name = "foo" + // } + code := `name = "foo"` + file, diags := hclsyntax.ParseConfig([]byte(code), ".tflint.hcl", hcl.InitialPos) + if diags.HasErrors() { + return &hclext.BodyContent{}, &hcl.File{}, diags + } + + body, diags := hclext.Content(file.Body, schema) + if diags.HasErrors() { + return &hclext.BodyContent{}, &hcl.File{}, diags + } + return body, file, nil + }, + Want: &ruleConfig{Name: "foo"}, + ErrCheck: neverHappend, + }, + { + Name: "server returns an error", + RuleName: "test_rule", + Target: &ruleConfig{}, + ServerImpl: func(name string, schema *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) { + return nil, nil, errors.New("unexpected error") + }, + Want: &ruleConfig{}, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "unexpected error" + }, + }, + { + Name: "response body is empty", + RuleName: "test_rule", + Target: &ruleConfig{}, + ServerImpl: func(name string, schema *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) { + return nil, nil, nil + }, + Want: &ruleConfig{}, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "response body is empty" + }, + }, + { + Name: "config not found", + RuleName: "not_found", + Target: &ruleConfig{}, + ServerImpl: func(name string, schema *hclext.BodySchema) (*hclext.BodyContent, *hcl.File, error) { + return &hclext.BodyContent{}, nil, nil + }, + Want: &ruleConfig{}, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "config file not found" + }, + }, + { + Name: "name is empty", + RuleName: "", + Target: &ruleConfig{}, + Want: &ruleConfig{}, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "name should not be empty" + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + client := startTestGRPCServer(t, newMockServer(mockServerImpl{getRuleConfigContent: test.ServerImpl})) + + err := client.DecodeRuleConfig(test.RuleName, test.Target) + if test.ErrCheck(err) { + t.Fatalf("failed to call DecodeRuleConfig: %s", err) + } + + if diff := cmp.Diff(test.Target, test.Want); diff != "" { + t.Errorf("diff: %s", diff) + } + }) + } +} + +func TestEvaluateExpr(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + // default getFileImpl function + fileIdx := 1 + files := map[string]*hcl.File{} + fileExists := func(filename string) (*hcl.File, error) { + return files[filename], nil + } + + // test util functions + hclExpr := func(expr string) hcl.Expression { + filename := fmt.Sprintf("test%d.tf", fileIdx) + file, diags := hclsyntax.ParseConfig([]byte(fmt.Sprintf(`expr = %s`, expr)), filename, hcl.InitialPos) + if diags.HasErrors() { + panic(diags) + } + attributes, diags := file.Body.JustAttributes() + if diags.HasErrors() { + panic(diags) + } + files[filename] = file + fileIdx = fileIdx + 1 + return attributes["expr"].Expr + } + jsonExpr := func(expr string) hcl.Expression { + filename := fmt.Sprintf("test%d.tf.json", fileIdx) + file, diags := json.Parse([]byte(fmt.Sprintf(`{"expr": %s}`, expr)), filename) + if diags.HasErrors() { + panic(diags) + } + attributes, diags := file.Body.JustAttributes() + if diags.HasErrors() { + panic(diags) + } + files[filename] = file + fileIdx = fileIdx + 1 + return attributes["expr"].Expr + } + evalExpr := func(expr hcl.Expression, ctx *hcl.EvalContext) (cty.Value, error) { + val, diags := expr.Value(ctx) + if diags.HasErrors() { + return cty.Value{}, diags + } + return val, nil + } + + // test struct for decoding from cty.Value + type Object struct { + Name string `cty:"name"` + Enabled bool `cty:"enabled"` + } + objectTy := cty.Object(map[string]cty.Type{"name": cty.String, "enabled": cty.Bool}) + + tests := []struct { + Name string + Expr hcl.Expression + TargetType reflect.Type + Option *tflint.EvaluateExprOption + ServerImpl func(hcl.Expression, tflint.EvaluateExprOption) (cty.Value, error) + GetFileImpl func(string) (*hcl.File, error) + Want interface{} + ErrCheck func(err error) bool + }{ + { + Name: "literal", + Expr: hclExpr(`"foo"`), + TargetType: reflect.TypeOf(""), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.String { + return cty.Value{}, errors.New("wantType should be string") + } + if opts.ModuleCtx != tflint.SelfModuleCtxType { + return cty.Value{}, errors.New("moduleCtx should be self") + } + return evalExpr(expr, nil) + }, + Want: "foo", + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "string variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf(""), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + }, + }) + }, + Want: "bar", + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "number variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf(0), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.Number { + return cty.Value{}, errors.New("wantType should be number") + } + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(7), + }), + }, + }) + }, + Want: 7, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "string list variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf([]string{}), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.List(cty.String) { + return cty.Value{}, errors.New("wantType should be string list") + } + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), + }), + }, + }) + }, + Want: []string{"foo", "bar"}, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "number list variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf([]int{}), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.List(cty.Number) { + return cty.Value{}, errors.New("wantType should be number list") + } + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{cty.NumberIntVal(1), cty.NumberIntVal(2)}), + }), + }, + }) + }, + Want: []int{1, 2}, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "string map variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf(map[string]string{}), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.Map(cty.String) { + return cty.Value{}, errors.New("wantType should be string map") + } + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{"foo": cty.StringVal("bar"), "baz": cty.StringVal("qux")}), + }), + }, + }) + }, + Want: map[string]string{"foo": "bar", "baz": "qux"}, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "number map variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf(map[string]int{}), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.Map(cty.Number) { + return cty.Value{}, errors.New("wantType should be number map") + } + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{"foo": cty.NumberIntVal(1), "bar": cty.NumberIntVal(2)}), + }), + }, + }) + }, + Want: map[string]int{"foo": 1, "bar": 2}, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "object variable", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf(cty.Value{}), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if *opts.WantType != cty.DynamicPseudoType { + return cty.Value{}, errors.New("wantType should be pseudo type") + } + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(1), + "bar": cty.StringVal("baz"), + "qux": cty.UnknownVal(cty.String), + }), + }), + }, + }) + }, + Want: cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(1), + "bar": cty.StringVal("baz"), + "qux": cty.UnknownVal(cty.String), + }), + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "object variable to struct", + Expr: hclExpr(`var.foo`), + TargetType: reflect.TypeOf(Object{}), + Option: &tflint.EvaluateExprOption{WantType: &objectTy}, + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("bar"), + "enabled": cty.BoolVal(true), + }), + }), + }, + }) + }, + Want: Object{Name: "bar", Enabled: true}, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "JSON expr", + Expr: jsonExpr(`"${var.foo}"`), + TargetType: reflect.TypeOf(""), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + return evalExpr(expr, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.MapVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + }, + }) + }, + Want: "bar", + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "JSON object", + Expr: jsonExpr(`{"foo": "bar"}`), + TargetType: reflect.TypeOf(map[string]string{}), + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + return evalExpr(expr, nil) + }, + Want: map[string]string{"foo": "bar"}, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "eval with moduleCtx option", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + Option: &tflint.EvaluateExprOption{ModuleCtx: tflint.RootModuleCtxType}, + ServerImpl: func(expr hcl.Expression, opts tflint.EvaluateExprOption) (cty.Value, error) { + if opts.ModuleCtx != tflint.RootModuleCtxType { + return cty.Value{}, errors.New("moduleCtx should be root") + } + return evalExpr(expr, nil) + }, + Want: 1, + GetFileImpl: fileExists, + ErrCheck: neverHappend, + }, + { + Name: "getFile returns no file", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + Want: 0, + GetFileImpl: func(string) (*hcl.File, error) { + return nil, nil + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "file not found" + }, + }, + { + Name: "getFile returns an error", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + Want: 0, + GetFileImpl: func(string) (*hcl.File, error) { + return nil, errors.New("unexpected error") + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "unexpected error" + }, + }, + { + Name: "server returns an unexpected error", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + ServerImpl: func(hcl.Expression, tflint.EvaluateExprOption) (cty.Value, error) { + return cty.Value{}, errors.New("unexpected error") + }, + Want: 0, + GetFileImpl: fileExists, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "unexpected error" + }, + }, + { + Name: "server returns an unknown error", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + ServerImpl: func(hcl.Expression, tflint.EvaluateExprOption) (cty.Value, error) { + return cty.Value{}, fmt.Errorf("unknown%w", tflint.ErrUnknownValue) + }, + Want: 0, + GetFileImpl: fileExists, + ErrCheck: func(err error) bool { + return !errors.Is(err, tflint.ErrUnknownValue) + }, + }, + { + Name: "server returns a null value error", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + ServerImpl: func(hcl.Expression, tflint.EvaluateExprOption) (cty.Value, error) { + return cty.Value{}, fmt.Errorf("null value%w", tflint.ErrNullValue) + }, + Want: 0, + GetFileImpl: fileExists, + ErrCheck: func(err error) bool { + return !errors.Is(err, tflint.ErrNullValue) + }, + }, + { + Name: "server returns a unevaluable error", + Expr: hclExpr(`1`), + TargetType: reflect.TypeOf(0), + ServerImpl: func(hcl.Expression, tflint.EvaluateExprOption) (cty.Value, error) { + return cty.Value{}, fmt.Errorf("unevaluable%w", tflint.ErrUnevaluable) + }, + Want: 0, + GetFileImpl: fileExists, + ErrCheck: func(err error) bool { + return !errors.Is(err, tflint.ErrUnevaluable) + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + target := reflect.New(test.TargetType) + + client := startTestGRPCServer(t, newMockServer(mockServerImpl{evaluateExpr: test.ServerImpl, getFile: test.GetFileImpl})) + + err := client.EvaluateExpr(test.Expr, target.Interface(), test.Option) + if test.ErrCheck(err) { + t.Fatalf("failed to call EvaluateExpr: %s", err) + } + + got := target.Elem().Interface() + + opts := cmp.Options{ + cmp.Comparer(func(x, y cty.Value) bool { + return x.GoString() == y.GoString() + }), + } + if diff := cmp.Diff(got, test.Want, opts); diff != "" { + t.Errorf("diff: %s", diff) + } + }) + } +} + +// test rule for TestEmitIssue +type Rule struct { + tflint.DefaultRule +} + +func (*Rule) Name() string { return "test_rule" } +func (*Rule) Enabled() bool { return true } +func (*Rule) Severity() tflint.Severity { return tflint.ERROR } +func (*Rule) Link() string { return "https://example.com" } +func (*Rule) Check(runner tflint.Runner) error { return nil } + +func TestEmitIssue(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + tests := []struct { + Name string + Args func() (tflint.Rule, string, hcl.Range) + ServerImpl func(tflint.Rule, string, hcl.Range) error + ErrCheck func(error) bool + }{ + { + Name: "emit issue", + Args: func() (tflint.Rule, string, hcl.Range) { + return &Rule{}, "this is test", hcl.Range{Filename: "test.tf", Start: hcl.Pos{Line: 2, Column: 2}, End: hcl.Pos{Line: 2, Column: 10}} + }, + ServerImpl: func(rule tflint.Rule, message string, location hcl.Range) error { + if rule.Name() != "test_rule" { + return fmt.Errorf("rule.Name() should be test_rule, but %s", rule.Name()) + } + if rule.Enabled() != true { + return fmt.Errorf("rule.Enabled() should be true, but %t", rule.Enabled()) + } + if rule.Severity() != tflint.ERROR { + return fmt.Errorf("rule.Severity() should be ERROR, but %s", rule.Severity()) + } + if rule.Link() != "https://example.com" { + return fmt.Errorf("rule.Link() should be https://example.com, but %s", rule.Link()) + } + if message != "this is test" { + return fmt.Errorf("message should be `this is test`, but %s", message) + } + want := hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 2}, + End: hcl.Pos{Line: 2, Column: 10}, + } + if diff := cmp.Diff(location, want); diff != "" { + return fmt.Errorf("diff: %s", diff) + } + return nil + }, + ErrCheck: neverHappend, + }, + { + Name: "server returns an error", + Args: func() (tflint.Rule, string, hcl.Range) { + return &Rule{}, "this is test", hcl.Range{Filename: "test.tf", Start: hcl.Pos{Line: 2, Column: 2}, End: hcl.Pos{Line: 2, Column: 10}} + }, + ServerImpl: func(tflint.Rule, string, hcl.Range) error { + return errors.New("unexpected error") + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "unexpected error" + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + client := startTestGRPCServer(t, newMockServer(mockServerImpl{emitIssue: test.ServerImpl})) + + err := client.EmitIssue(test.Args()) + if test.ErrCheck(err) { + t.Fatalf("failed to call EmitIssue: %s", err) + } + }) + } +} + +func TestEnsureNoError(t *testing.T) { + // default error check helper + neverHappend := func(err error) bool { return err != nil } + + tests := []struct { + Name string + Err error + Proc func() error + ErrCheck func(error) bool + }{ + { + Name: "no errors", + Err: nil, + Proc: func() error { + return errors.New("should be called") + }, + ErrCheck: func(err error) bool { + // should be passed result of proc() + return err == nil || err.Error() != "should be called" + }, + }, + { + Name: "ErrUnevaluable", + Err: fmt.Errorf("unevaluable%w", tflint.ErrUnevaluable), + Proc: func() error { + return errors.New("should not be called") + }, + ErrCheck: neverHappend, + }, + { + Name: "ErrNullValue", + Err: fmt.Errorf("null value%w", tflint.ErrNullValue), + Proc: func() error { + return errors.New("should not be called") + }, + ErrCheck: neverHappend, + }, + { + Name: "ErrUnknownValue", + Err: fmt.Errorf("unknown value%w", tflint.ErrUnknownValue), + Proc: func() error { + return errors.New("should not be called") + }, + ErrCheck: neverHappend, + }, + { + Name: "unexpected error", + Err: errors.New("unexpected error"), + Proc: func() error { + return errors.New("should not be called") + }, + ErrCheck: func(err error) bool { + return err == nil || err.Error() != "unexpected error" + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + client := startTestGRPCServer(t, newMockServer(mockServerImpl{})) + + err := client.EnsureNoError(test.Err, test.Proc) + if test.ErrCheck(err) { + t.Fatalf("failed to call EnsureNoError: %s", err) + } + }) + } +} diff --git a/plugin/proto/doc.go b/plugin/proto/doc.go new file mode 100644 index 0000000..9a5e31e --- /dev/null +++ b/plugin/proto/doc.go @@ -0,0 +1,6 @@ +// Package proto contains generated protocol buffers structures. +// This package is not intended to be used directly from plugins. +// +// Do not include anything other than automatically generated ones here. +// If you want to change it, change the *.proto and run `make proto`. +package proto diff --git a/plugin/toproto/doc.go b/plugin/toproto/doc.go new file mode 100644 index 0000000..ebcad11 --- /dev/null +++ b/plugin/toproto/doc.go @@ -0,0 +1,9 @@ +// Package toproto contains an implementation to encode a Go structure +// into a structure generated from *.proto. This package is not intended +// to be used directly from plugins. +// +// Many primitives can be handled as-is, but some interfaces and errors +// require special encoding. The `hcl.Expression` encodes into the range +// and the text representation as bytes. Error is encoded into gRPC error +// details to represent wrapped errors. +package toproto