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 }