github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/pkg/test/live/runner.go (about)

     1  // Copyright 2021 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package live
    16  
    17  import (
    18  	"bufio"
    19  	"bytes"
    20  	"errors"
    21  	"fmt"
    22  	"os"
    23  	"os/exec"
    24  	"path/filepath"
    25  	"regexp"
    26  	"sort"
    27  	"strings"
    28  	"testing"
    29  
    30  	"github.com/stretchr/testify/assert"
    31  	"sigs.k8s.io/cli-utils/pkg/kstatus/status"
    32  	"sigs.k8s.io/cli-utils/pkg/testutil"
    33  	"sigs.k8s.io/kustomize/kyaml/yaml"
    34  )
    35  
    36  // Runner uses the provided Config to run a test.
    37  type Runner struct {
    38  	// Config provides the configuration for how this test should be
    39  	// executed.
    40  	Config TestCaseConfig
    41  
    42  	// Path provides the path to the test files.
    43  	Path string
    44  }
    45  
    46  // Run executes the test.
    47  func (r *Runner) Run(t *testing.T) {
    48  	testName := filepath.Base(r.Path)
    49  	r.RunPreApply(t)
    50  
    51  	stdout, stderr, err := r.RunApply(t)
    52  	r.VerifyExitCode(t, err)
    53  	r.VerifyStdout(t, stdout)
    54  	r.VerifyStderr(t, stderr)
    55  	if len(r.Config.Inventory) != 0 {
    56  		r.VerifyInventory(t, testName, testName)
    57  	}
    58  }
    59  
    60  func (r *Runner) RunPreApply(t *testing.T) {
    61  	preApplyDir := filepath.Join(r.Path, "pre-apply")
    62  	fi, err := os.Stat(preApplyDir)
    63  	if err != nil && !os.IsNotExist(err) {
    64  		t.Fatalf("error checking for pre-apply dir: %v", err)
    65  	}
    66  	if os.IsNotExist(err) || !fi.IsDir() {
    67  		return
    68  	}
    69  	t.Log("Applying resources in pre-apply directory")
    70  	cmd := exec.Command("kubectl", "apply", "-f", preApplyDir)
    71  	if err := cmd.Run(); err != nil {
    72  		t.Fatalf("error applying pre-apply dir: %v", err)
    73  	}
    74  }
    75  
    76  func (r *Runner) RunApply(t *testing.T) (string, string, error) {
    77  	t.Logf("Running command: kpt %s", strings.Join(r.Config.KptArgs, " "))
    78  	cmd := exec.Command("kpt", r.Config.KptArgs...)
    79  	cmd.Dir = filepath.Join(r.Path, "resources")
    80  
    81  	var outBuf bytes.Buffer
    82  	var errBuf bytes.Buffer
    83  	cmd.Stdout = &outBuf
    84  	cmd.Stderr = &errBuf
    85  
    86  	err := cmd.Run()
    87  	return outBuf.String(), errBuf.String(), err
    88  }
    89  
    90  func (r *Runner) VerifyExitCode(t *testing.T, err error) {
    91  	exitCode := 0
    92  	var exitErr *exec.ExitError
    93  	if errors.As(err, &exitErr) {
    94  		exitCode = exitErr.ExitCode()
    95  	}
    96  	if want, got := r.Config.ExitCode, exitCode; want != got {
    97  		t.Errorf("expected exit code %d, but got %d", want, got)
    98  	}
    99  }
   100  
   101  func (r *Runner) VerifyStdout(t *testing.T, stdout string) {
   102  	testutil.AssertEqual(t, strings.TrimSpace(r.Config.StdOut), r.prepOutput(t, stdout))
   103  }
   104  
   105  func (r *Runner) VerifyStderr(t *testing.T, stderr string) {
   106  	testutil.AssertEqual(t, strings.TrimSpace(r.Config.StdErr), r.prepOutput(t, stderr))
   107  }
   108  
   109  func (r *Runner) prepOutput(t *testing.T, txt string) string {
   110  	txt = removeStatusEvents(t, txt)
   111  	txt = substituteTimestamps(txt)
   112  	txt = substituteUIDs(txt)
   113  	txt = substituteResourceVersion(txt)
   114  	txt = r.removeOptionalEvents(t, txt)
   115  	txt = removeClientSideThrottlingEvents(t, txt)
   116  	return strings.TrimSpace(txt)
   117  }
   118  
   119  func (r *Runner) VerifyInventory(t *testing.T, name, namespace string) {
   120  	rgExec := exec.Command("kubectl", "get", "resourcegroups.kpt.dev",
   121  		"-n", namespace, name, "-oyaml")
   122  	var outBuf bytes.Buffer
   123  	var errBuf bytes.Buffer
   124  	rgExec.Stdout = &outBuf
   125  	rgExec.Stderr = &errBuf
   126  	err := rgExec.Run()
   127  	if strings.Contains(errBuf.String(), "NotFound") {
   128  		t.Errorf("inventory with namespace %s and name %s not found",
   129  			namespace, name)
   130  		return
   131  	}
   132  	if err != nil {
   133  		t.Fatalf("error looking up resource group: %v", err)
   134  	}
   135  	var rg map[string]interface{}
   136  	err = yaml.Unmarshal(outBuf.Bytes(), &rg)
   137  	if err != nil {
   138  		t.Fatalf("error unmarshalling inventory object: %v", err)
   139  	}
   140  
   141  	var inventory []InventoryEntry
   142  	if rg["spec"] != nil {
   143  		spec := rg["spec"].(map[string]interface{})
   144  		if spec["resources"] != nil {
   145  			resources := spec["resources"].([]interface{})
   146  			for i := range resources {
   147  				r := resources[i].(map[string]interface{})
   148  				inventory = append(inventory, InventoryEntry{
   149  					Group:     r["group"].(string),
   150  					Kind:      r["kind"].(string),
   151  					Name:      r["name"].(string),
   152  					Namespace: r["namespace"].(string),
   153  				})
   154  			}
   155  		}
   156  	}
   157  
   158  	expectedInventory := r.Config.Inventory
   159  	sort.Slice(expectedInventory, inventorySortFunc(expectedInventory))
   160  	sort.Slice(inventory, inventorySortFunc(inventory))
   161  
   162  	assert.Equal(t, expectedInventory, inventory)
   163  }
   164  
   165  func inventorySortFunc(inv []InventoryEntry) func(i, j int) bool {
   166  	return func(i, j int) bool {
   167  		iInv := inv[i]
   168  		jInv := inv[j]
   169  
   170  		if iInv.Group != jInv.Group {
   171  			return iInv.Group < jInv.Group
   172  		}
   173  		if iInv.Kind != jInv.Kind {
   174  			return iInv.Kind < jInv.Kind
   175  		}
   176  		if iInv.Name != jInv.Name {
   177  			return iInv.Name < jInv.Name
   178  		}
   179  		return iInv.Namespace < jInv.Namespace
   180  	}
   181  }
   182  
   183  var timestampRegexp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`)
   184  
   185  func substituteTimestamps(text string) string {
   186  	return timestampRegexp.ReplaceAllString(text, "<TIMESTAMP>")
   187  }
   188  
   189  var uidRegexp = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
   190  
   191  func substituteUIDs(text string) string {
   192  	return uidRegexp.ReplaceAllLiteralString(text, "<UID>")
   193  }
   194  
   195  var resourceVersionRegexp = regexp.MustCompile(`resourceVersion: "[0-9]+"`)
   196  
   197  func substituteResourceVersion(text string) string {
   198  	return resourceVersionRegexp.ReplaceAllLiteralString(text, "resourceVersion: \"<RV>\"")
   199  }
   200  
   201  // statuses is a list of all status enums from the kstatus library.
   202  var statuses = []string{
   203  	status.InProgressStatus.String(),
   204  	status.CurrentStatus.String(),
   205  	status.FailedStatus.String(),
   206  	status.TerminatingStatus.String(),
   207  	status.UnknownStatus.String(),
   208  	status.NotFoundStatus.String(),
   209  }
   210  
   211  // removeStatusEvents removes all lines from the input string that match the
   212  // StatusEvent printer output from the cli-utils event printer.
   213  func removeStatusEvents(t *testing.T, text string) string {
   214  	scanner := bufio.NewScanner(strings.NewReader(text))
   215  	var lines []string
   216  
   217  	// Match StatusEvent printer output
   218  	// https://github.com/kubernetes-sigs/cli-utils/blob/master/pkg/printers/events/formatter.go#L166
   219  	statusMatchStr := strings.Join(statuses, "|")
   220  	pattern := regexp.MustCompile(fmt.Sprintf("^(.*)/(.*) is (%s): (.*)", statusMatchStr))
   221  
   222  	for scanner.Scan() {
   223  		line := scanner.Text()
   224  		if pattern.MatchString(line) {
   225  			continue
   226  		}
   227  		lines = append(lines, line)
   228  	}
   229  	if err := scanner.Err(); err != nil {
   230  		t.Fatalf("error scanning output: %v", err)
   231  	}
   232  	return strings.Join(lines, "\n")
   233  }
   234  
   235  // removeClientSideThrottlingEvents removes all lines from the input string that
   236  // match the client-side throttling log message from the client-go RESTClient.
   237  func removeClientSideThrottlingEvents(t *testing.T, text string) string {
   238  	scanner := bufio.NewScanner(strings.NewReader(text))
   239  	var lines []string
   240  
   241  	// Match RESTClient's client-side throtting error, which gets logged at
   242  	// level 1 if the delay is longer than 1s, and level 3 otherwise.
   243  	// https://github.com/kubernetes/client-go/blob/v0.24.0/rest/request.go#L529
   244  	pattern := regexp.MustCompile(".* Waited for .* due to client-side throttling, not priority and fairness, request: .*")
   245  
   246  	for scanner.Scan() {
   247  		line := scanner.Text()
   248  		if pattern.MatchString(line) {
   249  			continue
   250  		}
   251  		lines = append(lines, line)
   252  	}
   253  	if err := scanner.Err(); err != nil {
   254  		t.Fatalf("error scanning output: %v", err)
   255  	}
   256  	return strings.Join(lines, "\n")
   257  }
   258  
   259  // removeOptionalEvents removes all lines from the input string that exactly
   260  // match entries in the Runner.Config.OptionalStdOut list
   261  func (r *Runner) removeOptionalEvents(t *testing.T, text string) string {
   262  	scanner := bufio.NewScanner(strings.NewReader(text))
   263  	var lines []string
   264  
   265  	for scanner.Scan() {
   266  		line := scanner.Text()
   267  		if equalsAny(line, r.Config.OptionalStdOut) {
   268  			continue
   269  		}
   270  		lines = append(lines, line)
   271  	}
   272  	if err := scanner.Err(); err != nil {
   273  		t.Fatalf("error scanning output: %v", err)
   274  	}
   275  	return strings.Join(lines, "\n")
   276  }
   277  
   278  func equalsAny(s string, strs []string) bool {
   279  	for _, str := range strs {
   280  		if str == s {
   281  			return true
   282  		}
   283  	}
   284  	return false
   285  }