github.com/magnusbaeck/logstash-filter-verifier/v2@v2.0.0-pre.1/testcase/testcase.go (about)

     1  // Copyright (c) 2015-2018 Magnus Bäck <magnus@noun.se>
     2  
     3  package testcase
     4  
     5  import (
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"io/ioutil"
    11  	"os"
    12  	"os/exec"
    13  	"path/filepath"
    14  	"reflect"
    15  	"sort"
    16  	"strconv"
    17  	"strings"
    18  
    19  	unjson "github.com/hashicorp/packer/common/json"
    20  	"github.com/imkira/go-observer"
    21  	"github.com/magnusbaeck/logstash-filter-verifier/logging"
    22  	"github.com/magnusbaeck/logstash-filter-verifier/logstash"
    23  	lfvobserver "github.com/magnusbaeck/logstash-filter-verifier/observer"
    24  	"github.com/mikefarah/yaml/v2"
    25  )
    26  
    27  // TestCaseSet contains the configuration of a Logstash filter test case.
    28  // Most of the fields are supplied by the user via a JSON file or YAML file.
    29  type TestCaseSet struct {
    30  	// File is the absolute path to the file from which this
    31  	// test case was read.
    32  	File string `json:"-" yaml:"-"`
    33  
    34  	// Codec names the Logstash codec that should be used when
    35  	// events are read. This is normally "line" or "json_lines".
    36  	Codec string `json:"codec" yaml:"codec"`
    37  
    38  	// IgnoredFields contains a list of fields that will be
    39  	// deleted from the events that Logstash returns before
    40  	// they're compared to the events in ExpectedEevents.
    41  	//
    42  	// This can be used for skipping fields that Logstash
    43  	// populates with unpredictable contents (hostnames or
    44  	// timestamps) that can't be hard-wired into the test case
    45  	// file.
    46  	//
    47  	// It's also useful for the @version field that Logstash
    48  	// always adds with a constant value so that one doesn't have
    49  	// to include that field in every event in ExpectedEvents.
    50  	IgnoredFields []string `json:"ignore" yaml:"ignore"`
    51  
    52  	// InputFields contains a mapping of fields that should be
    53  	// added to input events, like "type" or "tags". The map
    54  	// values may be scalar values or arrays of scalar
    55  	// values. This is often important since filters typically are
    56  	// configured based on the event's type or its tags.
    57  	InputFields logstash.FieldSet `json:"fields" yaml:"fields"`
    58  
    59  	// InputLines contains the lines of input that should be fed
    60  	// to the Logstash process.
    61  	InputLines []string `json:"input" yaml:"input"`
    62  
    63  	// ExpectedEvents contains a slice of expected events to be
    64  	// compared to the actual events produced by the Logstash
    65  	// process.
    66  	ExpectedEvents []logstash.Event `json:"expected" yaml:"expected"`
    67  
    68  	// TestCases is a slice of test cases, which include at minimum
    69  	// a pair of an input and an expected event
    70  	// Optionally other information regarding the test case
    71  	// may be supplied.
    72  	TestCases []TestCase `json:"testcases" yaml:"testcases"`
    73  
    74  	descriptions []string `json:"descriptions" yaml:"descriptions"`
    75  }
    76  
    77  // TestCase is a pair of an input line that should be fed
    78  // into the Logstash process and an expected event which is compared
    79  // to the actual event produced by the Logstash process.
    80  type TestCase struct {
    81  	// InputLines contains the lines of input that should be fed
    82  	// to the Logstash process.
    83  	InputLines []string `json:"input" yaml:"input"`
    84  
    85  	// ExpectedEvents contains a slice of expected events to be
    86  	// compared to the actual events produced by the Logstash
    87  	// process.
    88  	ExpectedEvents []logstash.Event `json:"expected" yaml:"expected"`
    89  
    90  	// Description contains an optional description of the test case
    91  	// which will be printed while the tests are executed.
    92  	Description string `json:"description" yaml:"description"`
    93  }
    94  
    95  var (
    96  	log = logging.MustGetLogger()
    97  
    98  	defaultIgnoredFields = []string{"@version"}
    99  )
   100  
   101  // convertBracketFields permit to replace keys that contains bracket with sub structure.
   102  // For example, the key `[log][file][path]` will be convert by `"log": {"file": {"path": "VALUE"}}`.
   103  func (tcs *TestCaseSet) convertBracketFields() error {
   104  	// Convert fields in input fields
   105  	tcs.InputFields = parseAllBracketProperties(tcs.InputFields)
   106  
   107  	// Convert fields in expected events
   108  	for i, expected := range tcs.ExpectedEvents {
   109  		tcs.ExpectedEvents[i] = parseAllBracketProperties(expected)
   110  	}
   111  
   112  	// Convert fields in input json string
   113  	if tcs.Codec == "json_lines" {
   114  		for i, line := range tcs.InputLines {
   115  			var jsonObj map[string]interface{}
   116  			if err := json.Unmarshal([]byte(line), &jsonObj); err != nil {
   117  				return err
   118  			}
   119  			jsonObj = parseAllBracketProperties(jsonObj)
   120  			data, err := json.Marshal(jsonObj)
   121  			if err != nil {
   122  				return err
   123  			}
   124  			tcs.InputLines[i] = string(data)
   125  		}
   126  	}
   127  
   128  	return nil
   129  }
   130  
   131  // New reads a test case configuration from a reader and returns a
   132  // TestCase. Defaults to a "line" codec and ignoring the @version
   133  // field. If the configuration being read lists additional fields to
   134  // ignore those will be ignored in addition to @version.
   135  // configType must be json or yaml or yml.
   136  func New(reader io.Reader, configType string) (*TestCaseSet, error) {
   137  	if configType != "json" && configType != "yaml" && configType != "yml" {
   138  		return nil, errors.New("Config type must be json or yaml or yml")
   139  	}
   140  
   141  	tcs := TestCaseSet{
   142  		Codec:       "line",
   143  		InputFields: logstash.FieldSet{},
   144  	}
   145  	buf, err := ioutil.ReadAll(reader)
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  
   150  	if configType == "json" {
   151  		if err = unjson.Unmarshal(buf, &tcs); err != nil {
   152  			return nil, err
   153  		}
   154  	} else {
   155  		// Fix issue https://github.com/go-yaml/yaml/issues/139
   156  		yaml.DefaultMapType = reflect.TypeOf(map[string]interface{}{})
   157  		if err = yaml.Unmarshal(buf, &tcs); err != nil {
   158  			return nil, err
   159  		}
   160  	}
   161  
   162  	if err = tcs.InputFields.IsValid(); err != nil {
   163  		return nil, err
   164  	}
   165  	tcs.IgnoredFields = append(tcs.IgnoredFields, defaultIgnoredFields...)
   166  	sort.Strings(tcs.IgnoredFields)
   167  	tcs.descriptions = make([]string, len(tcs.ExpectedEvents))
   168  	for _, tc := range tcs.TestCases {
   169  		tcs.InputLines = append(tcs.InputLines, tc.InputLines...)
   170  		tcs.ExpectedEvents = append(tcs.ExpectedEvents, tc.ExpectedEvents...)
   171  		for range tc.ExpectedEvents {
   172  			tcs.descriptions = append(tcs.descriptions, tc.Description)
   173  		}
   174  	}
   175  
   176  	// Convert bracket fields
   177  	if err := tcs.convertBracketFields(); err != nil {
   178  		return nil, err
   179  	}
   180  
   181  	log.Debugf("Current TestCaseSet after converting fields: %+v", tcs)
   182  	return &tcs, nil
   183  }
   184  
   185  // NewFromFile reads a test case configuration from an on-disk file.
   186  func NewFromFile(path string) (*TestCaseSet, error) {
   187  	abspath, err := filepath.Abs(path)
   188  	if err != nil {
   189  		return nil, err
   190  	}
   191  	ext := strings.TrimPrefix(filepath.Ext(abspath), ".")
   192  
   193  	log.Debugf("Reading test case file: %s (%s)", path, abspath)
   194  	f, err := os.Open(path)
   195  	if err != nil {
   196  		return nil, err
   197  	}
   198  	defer func() {
   199  		_ = f.Close()
   200  	}()
   201  
   202  	tcs, err := New(f, ext)
   203  	if err != nil {
   204  		return nil, fmt.Errorf("Error reading/unmarshalling %s: %s", path, err)
   205  	}
   206  	tcs.File = abspath
   207  	return tcs, nil
   208  }
   209  
   210  // Compare compares a slice of events against the expected events of
   211  // this test case. Each event is written pretty-printed to a temporary
   212  // file and the two files are passed to the diff command. Its output is
   213  // is sent to the observer via an lfvobserver.ComparisonResult struct.
   214  // Returns true if the current test case passes, otherwise false. A non-nil
   215  // error value indicates a problem executing the test.
   216  func (tcs *TestCaseSet) Compare(events []logstash.Event, diffCommand []string, liveProducer observer.Property) (bool, error) {
   217  	status := true
   218  
   219  	// Don't even attempt to do a deep comparison of the event
   220  	// lists unless their lengths are equal.
   221  	if len(tcs.ExpectedEvents) != len(events) {
   222  		comparisonResult := lfvobserver.ComparisonResult{
   223  			Status:     false,
   224  			Name:       "Compare actual event with expected event",
   225  			Explain:    fmt.Sprintf("Expected %d event(s), got %d instead.", len(tcs.ExpectedEvents), len(events)),
   226  			Path:       filepath.Base(tcs.File),
   227  			EventIndex: 0,
   228  		}
   229  		liveProducer.Update(comparisonResult)
   230  		return false, nil
   231  	}
   232  
   233  	// Make sure we produce a result even if there are zero events (i.e. we
   234  	// won't enter the for loop below).
   235  	if len(events) == 0 {
   236  		comparisonResult := lfvobserver.ComparisonResult{
   237  			Status:     true,
   238  			Name:       "Compare actual event with expected event",
   239  			Explain:    "Drop all events",
   240  			Path:       filepath.Base(tcs.File),
   241  			EventIndex: 0,
   242  		}
   243  		liveProducer.Update(comparisonResult)
   244  		return true, nil
   245  	}
   246  
   247  	tempdir, err := ioutil.TempDir("", "")
   248  	if err != nil {
   249  		return false, err
   250  	}
   251  	defer func() {
   252  		if err := os.RemoveAll(tempdir); err != nil {
   253  			log.Errorf("Problem deleting temporary directory: %s", err)
   254  		}
   255  	}()
   256  
   257  	for i, actualEvent := range events {
   258  		comparisonResult := lfvobserver.ComparisonResult{
   259  			Path:       filepath.Base(tcs.File),
   260  			EventIndex: i,
   261  			Status:     true,
   262  		}
   263  		if (len(tcs.descriptions) > i) && (len(tcs.descriptions[i]) > 0) {
   264  			comparisonResult.Name = fmt.Sprintf("Comparing message %d of %d (%s)", i+1, len(events), tcs.descriptions[i])
   265  		} else {
   266  			comparisonResult.Name = fmt.Sprintf("Comparing message %d of %d", i+1, len(events))
   267  		}
   268  
   269  		// Ignored fields can be in a sub object
   270  		for _, ignored := range tcs.IgnoredFields {
   271  			removeFields(ignored, actualEvent)
   272  		}
   273  
   274  		// Create a directory structure for the JSON file being
   275  		// compared that makes it easy for the user to identify
   276  		// the failing test case in the diff output:
   277  		// $TMP/<random>/<test case file>/<event #>/<actual|expected>
   278  		resultDir := filepath.Join(tempdir, filepath.Base(tcs.File), strconv.Itoa(i+1))
   279  		actualFilePath := filepath.Join(resultDir, "actual")
   280  		if err = marshalToFile(actualEvent, actualFilePath); err != nil {
   281  			return false, err
   282  		}
   283  		expectedFilePath := filepath.Join(resultDir, "expected")
   284  		if err = marshalToFile(tcs.ExpectedEvents[i], expectedFilePath); err != nil {
   285  			return false, err
   286  		}
   287  
   288  		comparisonResult.Status, comparisonResult.Explain, err = runDiffCommand(diffCommand, expectedFilePath, actualFilePath)
   289  		if err != nil {
   290  			return false, err
   291  		}
   292  		if !comparisonResult.Status {
   293  			status = false
   294  		}
   295  
   296  		liveProducer.Update(comparisonResult)
   297  	}
   298  
   299  	return status, nil
   300  }
   301  
   302  // marshalToFile pretty-prints a logstash.Event and writes it to a
   303  // file, creating the file's parent directories as necessary.
   304  func marshalToFile(event logstash.Event, filename string) error {
   305  	buf, err := json.MarshalIndent(event, "", "  ")
   306  	if err != nil {
   307  		return fmt.Errorf("Failed to marshal %+v as JSON: %s", event, err)
   308  	}
   309  	if err = os.MkdirAll(filepath.Dir(filename), 0700); err != nil {
   310  		return err
   311  	}
   312  	return ioutil.WriteFile(filename, []byte(string(buf)+"\n"), 0600)
   313  }
   314  
   315  // runDiffCommand passes two files to the supplied command (executable
   316  // path and optional arguments) and returns whether the files were
   317  // equal. The returned error value will be set if there was a problem
   318  // running the command or if it returned an exit status other than zero
   319  // or one. The latter is interpreted as "comparison performed successfully
   320  // but the files were different". The output of the diff command is
   321  // returned as a string.
   322  func runDiffCommand(command []string, file1, file2 string) (bool, string, error) {
   323  	fullCommand := append(command, file1)
   324  	fullCommand = append(fullCommand, file2)
   325  	/* #nosec */
   326  	c := exec.Command(fullCommand[0], fullCommand[1:]...)
   327  	stdoutStderr, err := c.CombinedOutput()
   328  
   329  	success := err == nil
   330  	if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
   331  		// Exit code 1 is expected when the files differ; just ignore it.
   332  		err = nil
   333  	}
   334  	return success, string(stdoutStderr), err
   335  }