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 }