From f76539b4534f19e3a9896bac4ce83db1a0588300 Mon Sep 17 00:00:00 2001 From: lasith-kg Date: Wed, 27 Dec 2023 00:14:14 +0000 Subject: [PATCH] (feat): Add testing for Config --- .gitignore | 1 + build/coverage.sh | 14 ++ go.mod | 1 + go.sum | 2 + internal/config/config.go | 46 ++--- internal/config/config_test.go | 340 +++++++++++++++++++++++++++++++++ internal/utils/testing.go | 28 +++ 7 files changed, 405 insertions(+), 27 deletions(-) create mode 100755 build/coverage.sh create mode 100644 internal/config/config_test.go create mode 100644 internal/utils/testing.go diff --git a/.gitignore b/.gitignore index ca838bd..fffbd1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /ebs-bootstrap* +coverage.html \ No newline at end of file diff --git a/build/coverage.sh b/build/coverage.sh new file mode 100755 index 0000000..24ebdb8 --- /dev/null +++ b/build/coverage.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +# https://stackoverflow.com/questions/59895/get-the-source-directory-of-a-bash-script-from-within-the-script-itself +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "${SCRIPT_DIR}/.." + +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out -o coverage.html +rm -rf coverage.out + +# The HTML file can either be opened locally or served through HTTP using a CLI tool like miniserve +# https://github.com/svenstaro/miniserve +# e.g `miniserve coverage.html` \ No newline at end of file diff --git a/go.mod b/go.mod index 662b26d..8b7bf8a 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require gopkg.in/yaml.v2 v2.4.0 require ( github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 3796867..868ecc5 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/config/config.go b/internal/config/config.go index 2e1f9c7..bd61bbe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,28 +25,16 @@ type Flag struct { } type Device struct { - Fs model.FileSystem `yaml:"fs"` - MountPoint string `yaml:"mountPoint"` - MountOptions model.MountOptions `yaml:"mountOptions"` - User string `yaml:"user"` - Group string `yaml:"group"` - Label string `yaml:"label"` - Permissions model.FilePermissions `yaml:"permissions"` - Mode model.Mode `yaml:"mode"` - Remount bool `yaml:"remount"` - ResizeFs bool `yaml:"resizeFs"` - ResizeThreshold float64 `yaml:"resizeThreshold"` + Fs model.FileSystem `yaml:"fs"` + MountPoint string `yaml:"mountPoint"` + User string `yaml:"user"` + Group string `yaml:"group"` + Label string `yaml:"label"` + Permissions model.FilePermissions `yaml:"permissions"` + Options `yaml:",inline"` } -type Defaults struct { - Mode model.Mode `yaml:"mode"` - Remount bool `yaml:"remount"` - MountOptions model.MountOptions `yaml:"mountOptions"` - ResizeFs bool `yaml:"resizeFs"` - ResizeThreshold float64 `yaml:"resizeThreshold"` -} - -type Overrides struct { +type Options struct { Mode model.Mode `yaml:"mode"` Remount bool `yaml:"remount"` MountOptions model.MountOptions `yaml:"mountOptions"` @@ -57,9 +45,9 @@ type Overrides struct { // We don't export "overrides" as this is an attribute that is used // internally to store the state of flag overrides type Config struct { - Defaults Defaults `yaml:"defaults"` + Defaults Options `yaml:"defaults"` Devices map[string]Device `yaml:"devices"` - overrides Overrides + overrides Options } func New(args []string) (*Config, error) { @@ -70,15 +58,18 @@ func New(args []string) (*Config, error) { return nil, fmt.Errorf("🔴 Failed to parse provided flags") } - // Create config structure - c := (&Config{}).setOverrides(f) - // Load config file into memory file, err := os.ReadFile(f.Config) if err != nil { - return nil, err + if os.IsNotExist(err) { + return nil, fmt.Errorf("🔴 %s: File not found", f.Config) + } + return nil, fmt.Errorf("🔴 %s: %v", f.Config, err) } + // Create config structure + c := &Config{} + // Unmarshal YAML file from memory into struct err = yaml.UnmarshalStrict(file, c) if err != nil { @@ -86,7 +77,8 @@ func New(args []string) (*Config, error) { return nil, fmt.Errorf("🔴 %s: Failed to ingest malformed config", f.Config) } - return c, nil + // Inject flag overrides into config + return c.setOverrides(f), nil } func parseFlags(program string, args []string) (*Flag, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..34975f8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,340 @@ +package config + +import ( + "fmt" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/reecetech/ebs-bootstrap/internal/model" + "github.com/reecetech/ebs-bootstrap/internal/utils" +) + +func TestParsing(t *testing.T) { + subtests := []struct { + Name string + Data []byte + ExpectedOutput *Config + ExpectedErr error + }{ + { + Name: "Valid Config", + Data: []byte(`--- +defaults: + mode: healthcheck +devices: + /dev/xvdf: + fs: xfs + mountPoint: /ifmx/dev/root + user: 0 + group: root + permissions: 755 + label: external-vol + resizeFs: true + resizeThreshold: 95 + remount: true`), + ExpectedOutput: &Config{ + Defaults: Options{ + Mode: model.Healthcheck, + }, + Devices: map[string]Device{ + "/dev/xvdf": { + Fs: model.Xfs, + MountPoint: "/ifmx/dev/root", + User: "0", + Group: "root", + Permissions: model.FilePermissions(0755), + Label: "external-vol", + Options: Options{ + ResizeFs: true, + ResizeThreshold: 95, + Remount: true, + }, + }, + }, + }, + ExpectedErr: nil, + }, + { + Name: "Unsupported Attribute", + Data: []byte(`unsupported: true`), + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 /tmp/*.yml: Failed to ingest malformed config"), + }, + { + Name: "Malformed YAML", + Data: []byte(`malformed:- true`), + ExpectedOutput: nil, + ExpectedErr: fmt.Errorf("🔴 /tmp/*.yml: Failed to ingest malformed config"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + configPath, err := createConfigFile(subtest.Data) + utils.CheckErrorGlob("createConfigFile()", t, nil, err) + defer os.Remove(configPath) + + c, err := New([]string{"ebs-bootstrap", "-config", configPath}) + utils.CheckErrorGlob("config.New()", t, subtest.ExpectedErr, err) + // Config contains the unexported attribute "overrides" + // We need to allow go-cmp to inspect the contents of unexported attributes + utils.CheckOutputCmp("config.New()", t, subtest.ExpectedOutput, c, cmp.AllowUnexported(Config{})) + }) + } +} + +func TestFlagParsing(t *testing.T) { + // Create a variable to the current working directory + d, err := os.Getwd() + utils.CheckErrorGlob("os.Getwd()", t, nil, err) + + subtests := []struct { + Name string + Args []string + ExpectedErr error + }{ + { + Name: "Invalid Config (Directory)", + Args: []string{"ebs-bootstrap", "-config", d}, + ExpectedErr: fmt.Errorf("🔴 %s: *", d), + }, + { + Name: "Invalid Config (Non-existent File)", + Args: []string{"ebs-bootstrap", "-config", "/doesnt-exist"}, + ExpectedErr: fmt.Errorf("🔴 /doesnt-exist: File not found"), + }, + { + Name: "Unsupported Flag", + Args: []string{"ebs-bootstrap", "-unsupported-flag"}, + ExpectedErr: fmt.Errorf("🔴 Failed to parse provided flags"), + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + _, err := New(subtest.Args) + utils.CheckErrorGlob("config.New()", t, subtest.ExpectedErr, err) + }) + } +} + +func TestOptions(t *testing.T) { + device := "/dev/xvdf" + subtests := []struct { + Name string + Data []byte + ExpectedOutput *Options + ExpectedErr error + }{ + { + Name: "Provide Non-Default Device Options", + Data: []byte(fmt.Sprintf(`--- +devices: + %s: + mode: prompt + remount: true + mountOptions: nouuid + resizeFs: true + resizeThreshold: 95`, device)), + ExpectedOutput: &Options{ + Mode: model.Prompt, + Remount: true, + MountOptions: "nouuid", + ResizeFs: true, + ResizeThreshold: 95, + }, + ExpectedErr: nil, + }, + { + Name: "Default Options for Non-Existent Device", + Data: []byte(`--- +devices: + /dev/null: ~`), + ExpectedOutput: &Options{ + Mode: model.Healthcheck, + Remount: false, + MountOptions: "defaults", + ResizeFs: false, + ResizeThreshold: 0, + }, + ExpectedErr: nil, + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + configPath, err := createConfigFile(subtest.Data) + utils.CheckErrorGlob("createConfigFile()", t, nil, err) + defer os.Remove(configPath) + + c, err := New([]string{"ebs-bootstrap", "-config", configPath}) + utils.CheckErrorGlob("config.New()", t, subtest.ExpectedErr, err) + + d := &Options{ + Mode: c.GetMode(device), + Remount: c.GetRemount(device), + MountOptions: c.GetMountOptions(device), + ResizeFs: c.GetResizeFs(device), + ResizeThreshold: c.GetResizeThreshold(device), + } + utils.CheckOutputCmp("config.New()", t, subtest.ExpectedOutput, d) + }) + } +} + +func TestFlagOptions(t *testing.T) { + device := "/dev/xvdf" + c, err := createConfigFile([]byte(fmt.Sprintf(`--- +devices: + %s: ~`, device))) + utils.CheckErrorGlob("createConfigFile()", t, nil, err) + defer os.Remove(c) + subtests := []struct { + Name string + Args []string + ExpectedOutput *Options + ExpectedErr error + }{ + { + Name: "Mode Flag Options", + Args: []string{"ebs-bootstrap", "-config", c, "-mode", string(model.Force)}, + ExpectedOutput: &Options{ + Mode: model.Force, + Remount: false, + MountOptions: "defaults", + ResizeFs: false, + ResizeThreshold: 0, + }, + ExpectedErr: nil, + }, + { + Name: "Mount Flag Options", + Args: []string{"ebs-bootstrap", "-config", c, "-remount", "-mount-options", "nouuid"}, + ExpectedOutput: &Options{ + Mode: model.Healthcheck, + Remount: true, + MountOptions: "nouuid", + ResizeFs: false, + ResizeThreshold: 0, + }, + ExpectedErr: nil, + }, + { + Name: "Resize Flag Options", + Args: []string{"ebs-bootstrap", "-config", c, "-resize-fs", "-resize-threshold", "95"}, + ExpectedOutput: &Options{ + Mode: model.Healthcheck, + Remount: false, + MountOptions: "defaults", + ResizeFs: true, + ResizeThreshold: 95, + }, + ExpectedErr: nil, + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + c, err := New(subtest.Args) + utils.CheckErrorGlob("config.New()", t, subtest.ExpectedErr, err) + + o := &Options{ + Mode: c.GetMode(device), + Remount: c.GetRemount(device), + MountOptions: c.GetMountOptions(device), + ResizeFs: c.GetResizeFs(device), + ResizeThreshold: c.GetResizeThreshold(device), + } + utils.CheckOutputCmp("config.New()", t, subtest.ExpectedOutput, o) + }) + } +} + +func TestDefaultOptions(t *testing.T) { + device := "/dev/xvdf" + subtests := []struct { + Name string + Data []byte + ExpectedOutput *Options + ExpectedErr error + }{ + { + Name: "Mode Default Options", + Data: []byte(fmt.Sprintf(`--- +defaults: + mode: force +devices: + %s: ~`, device)), + ExpectedOutput: &Options{ + Mode: model.Force, + Remount: false, + MountOptions: "defaults", + ResizeFs: false, + ResizeThreshold: 0, + }, + ExpectedErr: nil, + }, + { + Name: "Mount Default Options", + Data: []byte(fmt.Sprintf(`--- +defaults: + remount: true + mountOptions: nouuid +devices: + %s: ~`, device)), + ExpectedOutput: &Options{ + Mode: model.Healthcheck, + Remount: true, + MountOptions: "nouuid", + ResizeFs: false, + ResizeThreshold: 0, + }, + ExpectedErr: nil, + }, + { + Name: "Resize Default Options", + Data: []byte(fmt.Sprintf(`--- +defaults: + resizeFs: true + resizeThreshold: 95 +devices: + %s: ~`, device)), + ExpectedOutput: &Options{ + Mode: model.Healthcheck, + Remount: false, + MountOptions: "defaults", + ResizeFs: true, + ResizeThreshold: 95, + }, + ExpectedErr: nil, + }, + } + for _, subtest := range subtests { + t.Run(subtest.Name, func(t *testing.T) { + configPath, err := createConfigFile(subtest.Data) + utils.CheckErrorGlob("createConfigFile()", t, nil, err) + defer os.Remove(configPath) + + c, err := New([]string{"ebs-bootstrap", "-config", configPath}) + utils.CheckErrorGlob("config.New()", t, subtest.ExpectedErr, err) + + d := &Options{ + Mode: c.GetMode(device), + Remount: c.GetRemount(device), + MountOptions: c.GetMountOptions(device), + ResizeFs: c.GetResizeFs(device), + ResizeThreshold: c.GetResizeThreshold(device), + } + utils.CheckOutputCmp("config.New()", t, subtest.ExpectedOutput, d) + }) + } +} + +func createConfigFile(data []byte) (string, error) { + f, err := os.CreateTemp("", "config_test_*.yml") + if err != nil { + return "", fmt.Errorf("🔴 Failed to create temporary config file: %v", err) + } + defer f.Close() + if _, err := f.Write(data); err != nil { + return "", fmt.Errorf("🔴 Failed to write to temporary config file: %v", err) + } + return f.Name(), nil +} diff --git a/internal/utils/testing.go b/internal/utils/testing.go new file mode 100644 index 0000000..aad179e --- /dev/null +++ b/internal/utils/testing.go @@ -0,0 +1,28 @@ +package utils + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ryanuber/go-glob" +) + +func CheckOutputCmp(id string, t *testing.T, expected interface{}, actual interface{}, opts ...cmp.Option) { + if !cmp.Equal(expected, actual, opts...) { + t.Fatalf("%s [output] mismatch: Expected=%+v Actual=%+v", id, expected, actual) + } +} + +func CheckErrorGlob(id string, t *testing.T, pattern error, actual error) { + if actual != nil { + if pattern == nil { + t.Fatalf("%s [error] undetected: Actual=%v", id, actual) + return + } + // Perform a glob match of the error message. Glob matching is useful + // for error messages that contain dynamic attributes + if !glob.Glob(pattern.Error(), actual.Error()) { + t.Fatalf("%s [error] mismatch: Pattern=%v Actual=%v", id, pattern, actual) + } + } +}