github.com/kubeshop/testkube@v1.17.23/internal/app/api/v1/tests.go (about) 1 package v1 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "net/http" 8 "sort" 9 "strconv" 10 "strings" 11 12 "github.com/gofiber/fiber/v2" 13 "go.mongodb.org/mongo-driver/mongo" 14 "k8s.io/apimachinery/pkg/api/errors" 15 "k8s.io/apimachinery/pkg/util/yaml" 16 17 testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" 18 "github.com/kubeshop/testkube-operator/pkg/client/tests/v3" 19 testsclientv3 "github.com/kubeshop/testkube-operator/pkg/client/tests/v3" 20 "github.com/kubeshop/testkube-operator/pkg/secret" 21 "github.com/kubeshop/testkube/pkg/api/v1/testkube" 22 "github.com/kubeshop/testkube/pkg/crd" 23 "github.com/kubeshop/testkube/pkg/executor/client" 24 executionsmapper "github.com/kubeshop/testkube/pkg/mapper/executions" 25 testsmapper "github.com/kubeshop/testkube/pkg/mapper/tests" 26 "github.com/kubeshop/testkube/pkg/repository/result" 27 ) 28 29 // GetTestHandler is method for getting an existing test 30 func (s TestkubeAPI) GetTestHandler() fiber.Handler { 31 return func(c *fiber.Ctx) error { 32 name := c.Params("id") 33 if name == "" { 34 return s.Error(c, http.StatusBadRequest, fmt.Errorf("failed to get test: id cannot be empty")) 35 } 36 errPrefix := fmt.Sprintf("failed to get test %s", name) 37 crTest, err := s.TestsClient.Get(name) 38 if err != nil { 39 if errors.IsNotFound(err) { 40 return s.Error(c, http.StatusNotFound, fmt.Errorf("%s: client was unable to find test: %w", errPrefix, err)) 41 } 42 43 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client failed to find test: %w", errPrefix, err)) 44 } 45 46 test := testsmapper.MapTestCRToAPI(*crTest) 47 if c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML { 48 test.QuoteTestTextFields() 49 data, err := crd.GenerateYAML(crd.TemplateTest, []testkube.Test{test}) 50 if err != nil { 51 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not build CRD: %w", errPrefix, err)) 52 } 53 return s.getCRDs(c, data, err) 54 } 55 56 return c.JSON(test) 57 } 58 } 59 60 // GetTestWithExecutionHandler is method for getting an existing test with execution 61 func (s TestkubeAPI) GetTestWithExecutionHandler() fiber.Handler { 62 return func(c *fiber.Ctx) error { 63 name := c.Params("id") 64 if name == "" { 65 return s.Error(c, http.StatusBadRequest, fmt.Errorf("failed to get test with execution: id cannot be empty")) 66 } 67 errPrefix := fmt.Sprintf("failed to get test %s with execution", name) 68 crTest, err := s.TestsClient.Get(name) 69 if err != nil { 70 if errors.IsNotFound(err) { 71 return s.Error(c, http.StatusNotFound, fmt.Errorf("%s: client failed to find test: %w", errPrefix, err)) 72 } 73 74 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client failed to find test: %w", errPrefix, err)) 75 } 76 77 test := testsmapper.MapTestCRToAPI(*crTest) 78 if c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML { 79 test.QuoteTestTextFields() 80 data, err := crd.GenerateYAML(crd.TemplateTest, []testkube.Test{test}) 81 if err != nil { 82 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not build CRD: %w", errPrefix, err)) 83 } 84 return s.getCRDs(c, data, err) 85 } 86 87 ctx := c.Context() 88 execution, err := s.ExecutionResults.GetLatestByTest(ctx, name) 89 if err != nil && err != mongo.ErrNoDocuments { 90 return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: failed to get execution: %w", errPrefix, err)) 91 } 92 93 return c.JSON(testkube.TestWithExecution{ 94 Test: &test, 95 LatestExecution: execution, 96 }) 97 } 98 } 99 100 func (s TestkubeAPI) getFilteredTestList(c *fiber.Ctx) (*testsv3.TestList, error) { 101 102 crTests, err := s.TestsClient.List(c.Query("selector")) 103 if err != nil { 104 return nil, fmt.Errorf("client failed to list tests: %w", err) 105 } 106 107 search := c.Query("textSearch") 108 if search != "" { 109 // filter items array 110 for i := len(crTests.Items) - 1; i >= 0; i-- { 111 if !strings.Contains(crTests.Items[i].Name, search) { 112 crTests.Items = append(crTests.Items[:i], crTests.Items[i+1:]...) 113 } 114 } 115 } 116 117 testType := c.Query("type") 118 if testType != "" { 119 // filter items array 120 for i := len(crTests.Items) - 1; i >= 0; i-- { 121 if !strings.Contains(crTests.Items[i].Spec.Type_, testType) { 122 crTests.Items = append(crTests.Items[:i], crTests.Items[i+1:]...) 123 } 124 } 125 } 126 127 return crTests, nil 128 } 129 130 // ListTestsHandler is a method for getting list of all available tests 131 func (s TestkubeAPI) ListTestsHandler() fiber.Handler { 132 return func(c *fiber.Ctx) error { 133 errPrefix := "failed to list tests" 134 crTests, err := s.getFilteredTestList(c) 135 if err != nil { 136 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: unable to get filtered tests: %w", errPrefix, err)) 137 } 138 139 tests := testsmapper.MapTestListKubeToAPI(*crTests) 140 if c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML { 141 for i := range tests { 142 tests[i].QuoteTestTextFields() 143 } 144 145 data, err := crd.GenerateYAML(crd.TemplateTest, tests) 146 if err != nil { 147 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not build CRD: %w", errPrefix, err)) 148 } 149 return s.getCRDs(c, data, err) 150 } 151 152 return c.JSON(tests) 153 } 154 } 155 156 // ListTestsHandler is a method for getting list of all available tests 157 func (s TestkubeAPI) TestMetricsHandler() fiber.Handler { 158 return func(c *fiber.Ctx) error { 159 testName := c.Params("id") 160 161 const DefaultLimit = 0 162 limit, err := strconv.Atoi(c.Query("limit", strconv.Itoa(DefaultLimit))) 163 if err != nil { 164 limit = DefaultLimit 165 } 166 167 const DefaultLastDays = 7 168 last, err := strconv.Atoi(c.Query("last", strconv.Itoa(DefaultLastDays))) 169 if err != nil { 170 last = DefaultLastDays 171 } 172 173 metrics, err := s.ExecutionResults.GetTestMetrics(context.Background(), testName, limit, last) 174 if err != nil { 175 return s.Error(c, http.StatusInternalServerError, fmt.Errorf("failed to get metrics for test %s: %w", testName, err)) 176 } 177 178 return c.JSON(metrics) 179 } 180 } 181 182 // getLatestExecutions return latest executions either by starttime or endtime for tests 183 func (s TestkubeAPI) getLatestExecutions(ctx context.Context, testNames []string) (map[string]testkube.Execution, error) { 184 executions, err := s.ExecutionResults.GetLatestByTests(ctx, testNames) 185 if err != nil && err != mongo.ErrNoDocuments { 186 return nil, fmt.Errorf("could not get latest executions for tests %s sorted by start time: %w", testNames, err) 187 } 188 189 executionMap := make(map[string]testkube.Execution, len(executions)) 190 for i := range executions { 191 executionMap[executions[i].TestName] = executions[i] 192 } 193 return executionMap, nil 194 } 195 196 // ListTestWithExecutionsHandler is a method for getting list of all available test with latest executions 197 func (s TestkubeAPI) ListTestWithExecutionsHandler() fiber.Handler { 198 return func(c *fiber.Ctx) error { 199 errPrefix := "failed to list tests with executions" 200 crTests, err := s.getFilteredTestList(c) 201 if err != nil { 202 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: unable to get filtered tests: %w", errPrefix, err)) 203 } 204 205 tests := testsmapper.MapTestListKubeToAPI(*crTests) 206 if c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML { 207 for i := range tests { 208 tests[i].QuoteTestTextFields() 209 } 210 211 data, err := crd.GenerateYAML(crd.TemplateTest, tests) 212 if err != nil { 213 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not build CRD: %w", errPrefix, err)) 214 } 215 return s.getCRDs(c, data, err) 216 } 217 218 ctx := c.Context() 219 results := make([]testkube.TestWithExecutionSummary, 0, len(tests)) 220 testNames := make([]string, len(tests)) 221 for i := range tests { 222 testNames[i] = tests[i].Name 223 } 224 225 executionMap, err := s.getLatestExecutions(ctx, testNames) 226 if err != nil { 227 return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: could not get latest executions: %w", errPrefix, err)) 228 } 229 230 for i := range tests { 231 if execution, ok := executionMap[tests[i].Name]; ok { 232 results = append(results, testkube.TestWithExecutionSummary{ 233 Test: &tests[i], 234 LatestExecution: executionsmapper.MapToSummary(&execution), 235 }) 236 } else { 237 results = append(results, testkube.TestWithExecutionSummary{ 238 Test: &tests[i], 239 }) 240 } 241 } 242 243 sort.Slice(results, func(i, j int) bool { 244 iTime := results[i].Test.Created 245 if results[i].LatestExecution != nil { 246 iTime = results[i].LatestExecution.EndTime 247 if results[i].LatestExecution.StartTime.After(results[i].LatestExecution.EndTime) { 248 iTime = results[i].LatestExecution.StartTime 249 } 250 } 251 252 jTime := results[j].Test.Created 253 if results[j].LatestExecution != nil { 254 jTime = results[j].LatestExecution.EndTime 255 if results[j].LatestExecution.StartTime.After(results[j].LatestExecution.EndTime) { 256 jTime = results[j].LatestExecution.StartTime 257 } 258 } 259 260 return iTime.After(jTime) 261 }) 262 263 status := c.Query("status") 264 if status != "" { 265 statusList, err := testkube.ParseExecutionStatusList(status, ",") 266 if err != nil { 267 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: execution status filter invalid: %w", errPrefix, err)) 268 } 269 270 statusMap := statusList.ToMap() 271 // filter items array 272 for i := len(results) - 1; i >= 0; i-- { 273 if results[i].LatestExecution != nil && results[i].LatestExecution.Status != nil { 274 if _, ok := statusMap[*results[i].LatestExecution.Status]; ok { 275 continue 276 } 277 } 278 279 results = append(results[:i], results[i+1:]...) 280 } 281 } 282 283 var page, pageSize int 284 pageParam := c.Query("page", "") 285 if pageParam != "" { 286 pageSize = result.PageDefaultLimit 287 page, err = strconv.Atoi(pageParam) 288 if err != nil { 289 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: test page filter invalid: %w", errPrefix, err)) 290 } 291 } 292 293 pageSizeParam := c.Query("pageSize", "") 294 if pageSizeParam != "" { 295 pageSize, err = strconv.Atoi(pageSizeParam) 296 if err != nil { 297 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: test page size filter invalid: %w", errPrefix, err)) 298 } 299 } 300 301 if pageParam != "" || pageSizeParam != "" { 302 startPos := page * pageSize 303 endPos := (page + 1) * pageSize 304 if startPos < len(results) { 305 if endPos > len(results) { 306 endPos = len(results) 307 } 308 309 results = results[startPos:endPos] 310 } 311 } 312 313 return c.JSON(results) 314 } 315 } 316 317 // CreateTestHandler creates new test CR based on test content 318 func (s TestkubeAPI) CreateTestHandler() fiber.Handler { 319 return func(c *fiber.Ctx) error { 320 errPrefix := "failed to create test" 321 var test *testsv3.Test 322 var secrets map[string]string 323 if string(c.Request().Header.ContentType()) == mediaTypeYAML { 324 test = &testsv3.Test{} 325 testSpec := string(c.Body()) 326 decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewBufferString(testSpec), len(testSpec)) 327 if err := decoder.Decode(&test); err != nil { 328 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse yaml request: %w", errPrefix, err)) 329 } 330 331 errPrefix = errPrefix + " " + test.Name 332 } else { 333 var request testkube.TestUpsertRequest 334 err := c.BodyParser(&request) 335 if err != nil { 336 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: failed to unmarshal request: %w", errPrefix, err)) 337 } 338 339 err = testkube.ValidateUpsertTestRequest(request) 340 if err != nil { 341 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: invalid test: %w", errPrefix, err)) 342 } 343 344 errPrefix = errPrefix + " " + request.Name 345 if c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML { 346 request.QuoteTestTextFields() 347 data, err := crd.GenerateYAML(crd.TemplateTest, []testkube.TestUpsertRequest{request}) 348 if err != nil { 349 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not build CRD: %w", errPrefix, err)) 350 } 351 352 return s.getCRDs(c, data, err) 353 } 354 355 s.Log.Infow("creating test", "request", request) 356 357 test = testsmapper.MapUpsertToSpec(request) 358 test.Namespace = s.Namespace 359 if request.Content != nil && request.Content.Repository != nil && !s.disableSecretCreation { 360 secrets = createTestSecretsData(request.Content.Repository.Username, request.Content.Repository.Token) 361 } 362 } 363 364 createdTest, err := s.TestsClient.Create(test, s.disableSecretCreation, tests.Option{Secrets: secrets}) 365 366 s.Metrics.IncCreateTest(test.Spec.Type_, err) 367 368 if err != nil { 369 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could not create test: %w", errPrefix, err)) 370 } 371 372 c.Status(http.StatusCreated) 373 return c.JSON(createdTest) 374 } 375 } 376 377 // UpdateTestHandler updates an existing test CR based on test content 378 func (s TestkubeAPI) UpdateTestHandler() fiber.Handler { 379 return func(c *fiber.Ctx) error { 380 errPrefix := "failed to update test" 381 var request testkube.TestUpdateRequest 382 if string(c.Request().Header.ContentType()) == mediaTypeYAML { 383 var test testsv3.Test 384 testSpec := string(c.Body()) 385 decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewBufferString(testSpec), len(testSpec)) 386 if err := decoder.Decode(&test); err != nil { 387 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse yaml request: %w", errPrefix, err)) 388 } 389 390 request = testsmapper.MapSpecToUpdate(&test) 391 } else { 392 err := c.BodyParser(&request) 393 if err != nil { 394 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse json request: %w", errPrefix, err)) 395 } 396 } 397 398 var name string 399 if request.Name != nil { 400 name = *request.Name 401 errPrefix = errPrefix + " " + name 402 } 403 404 err := testkube.ValidateUpdateTestRequest(request) 405 if err != nil { 406 return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: invalid test: %w", errPrefix, err)) 407 } 408 409 // we need to get resource first and load its metadata.ResourceVersion 410 test, err := s.TestsClient.Get(name) 411 if err != nil { 412 if errors.IsNotFound(err) { 413 return s.Error(c, http.StatusNotFound, fmt.Errorf("%s: client could not find test: %w", errPrefix, err)) 414 } 415 416 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could not get test: %w", errPrefix, err)) 417 } 418 419 // map update test but load spec only to not override metadata.ResourceVersion 420 testSpec := testsmapper.MapUpdateToSpec(request, test) 421 422 s.Log.Infow("updating test", "request", request) 423 424 var option *tests.Option 425 if request.Content != nil && (*request.Content) != nil && (*request.Content).Repository != nil && *(*request.Content).Repository != nil { 426 username := (*(*request.Content).Repository).Username 427 token := (*(*request.Content).Repository).Token 428 if (username != nil || token != nil) && !s.disableSecretCreation { 429 data, err := s.SecretClient.Get(secret.GetMetadataName(name, client.SecretTest)) 430 if err != nil && !errors.IsNotFound(err) { 431 return s.Error(c, http.StatusBadGateway, err) 432 } 433 434 option = &tests.Option{Secrets: updateTestSecretsData(data, username, token)} 435 } 436 } 437 438 if isRepositoryEmpty(testSpec.Spec) { 439 testSpec.Spec.Content.Repository = nil 440 } 441 442 var updatedTest *testsv3.Test 443 if option != nil { 444 updatedTest, err = s.TestsClient.Update(testSpec, s.disableSecretCreation, *option) 445 } else { 446 updatedTest, err = s.TestsClient.Update(testSpec, s.disableSecretCreation) 447 } 448 449 s.Metrics.IncUpdateTest(testSpec.Spec.Type_, err) 450 451 if err != nil { 452 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could not update test %w", errPrefix, err)) 453 } 454 455 return c.JSON(updatedTest) 456 } 457 } 458 459 func isRepositoryEmpty(s testsv3.TestSpec) bool { 460 return s.Content != nil && 461 s.Content.Repository != nil && 462 s.Content.Repository.Type_ == "" && 463 s.Content.Repository.Uri == "" && 464 s.Content.Repository.Branch == "" && 465 s.Content.Repository.Path == "" && 466 s.Content.Repository.Commit == "" && 467 s.Content.Repository.WorkingDir == "" 468 } 469 470 // DeleteTestHandler is a method for deleting a test with id 471 func (s TestkubeAPI) DeleteTestHandler() fiber.Handler { 472 return func(c *fiber.Ctx) error { 473 name := c.Params("id") 474 if name == "" { 475 return s.Error(c, http.StatusBadRequest, fmt.Errorf("failed to delete test: id cannot be empty")) 476 } 477 errPrefix := fmt.Sprintf("failed to delete test %s", name) 478 err := s.TestsClient.Delete(name) 479 if err != nil { 480 if errors.IsNotFound(err) { 481 return s.Warn(c, http.StatusNotFound, fmt.Errorf("%s: client could not find test: %w", errPrefix, err)) 482 } 483 484 if _, ok := err.(*testsclientv3.DeleteDependenciesError); ok { 485 return s.Warn(c, http.StatusInternalServerError, fmt.Errorf("client deleted test %s but deleting test dependencies(secrets) returned errors: %w", name, err)) 486 } 487 488 return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: client could not delete test: %w", errPrefix, err)) 489 } 490 491 skipExecutions := c.Query("skipDeleteExecutions", "") 492 if skipExecutions != "true" { 493 // delete executions for test 494 if err = s.ExecutionResults.DeleteByTest(c.Context(), name); err != nil { 495 return s.Warn(c, http.StatusInternalServerError, fmt.Errorf("test %s was deleted but deleting test executions returned error: %w", name, err)) 496 } 497 } 498 499 return c.SendStatus(http.StatusNoContent) 500 } 501 } 502 503 // AbortTestHandler is a method for aborting a executions of a test with id 504 func (s TestkubeAPI) AbortTestHandler() fiber.Handler { 505 return func(c *fiber.Ctx) error { 506 ctx := c.Context() 507 name := c.Params("id") 508 if name == "" { 509 return s.Error(c, http.StatusBadRequest, fmt.Errorf("failed to abort test: id cannot be empty")) 510 } 511 errPrefix := fmt.Sprintf("failed to abort test %s", name) 512 filter := result.NewExecutionsFilter().WithTestName(name).WithStatus(string(testkube.RUNNING_ExecutionStatus)) 513 executions, err := s.ExecutionResults.GetExecutions(ctx, filter) 514 if err != nil { 515 if err == mongo.ErrNoDocuments { 516 return s.Error(c, http.StatusNotFound, fmt.Errorf("%s: executions with name %s not found", errPrefix, name)) 517 } 518 return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: could not get executions: %w", errPrefix, err)) 519 } 520 521 var results []testkube.ExecutionResult 522 for _, execution := range executions { 523 res, errAbort := s.Executor.Abort(ctx, &execution) 524 if errAbort != nil { 525 s.Log.Errorw("aborting execution failed", "execution", execution, "error", errAbort) 526 err = errAbort 527 } 528 s.Metrics.IncAbortTest(execution.TestType, res.IsFailed()) 529 results = append(results, *res) 530 } 531 532 return c.JSON(results) 533 } 534 } 535 536 // DeleteTestsHandler for deleting all tests 537 func (s TestkubeAPI) DeleteTestsHandler() fiber.Handler { 538 return func(c *fiber.Ctx) error { 539 errPrefix := "failed to delete tests" 540 var err error 541 var testNames []string 542 selector := c.Query("selector") 543 if selector == "" { 544 err = s.TestsClient.DeleteAll() 545 } else { 546 var testList *testsv3.TestList 547 testList, err = s.TestsClient.List(selector) 548 if err != nil { 549 if !errors.IsNotFound(err) { 550 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could not list tests: %w", errPrefix, err)) 551 } 552 } else { 553 for _, item := range testList.Items { 554 testNames = append(testNames, item.Name) 555 } 556 } 557 558 err = s.TestsClient.DeleteByLabels(selector) 559 } 560 561 if err != nil { 562 if errors.IsNotFound(err) { 563 return s.Warn(c, http.StatusNotFound, fmt.Errorf("%s: could not find tests: %w", errPrefix, err)) 564 } 565 566 return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: could not delete tests: %w", errPrefix, err)) 567 } 568 569 // delete all executions for tests 570 if selector == "" { 571 err = s.ExecutionResults.DeleteAll(c.Context()) 572 } else { 573 err = s.ExecutionResults.DeleteByTests(c.Context(), testNames) 574 } 575 576 if err != nil { 577 return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: could not delete executions: %w", errPrefix, err)) 578 } 579 580 return c.SendStatus(http.StatusNoContent) 581 } 582 } 583 584 func createTestSecretsData(username, token string) map[string]string { 585 if username == "" && token == "" { 586 return nil 587 } 588 589 data := make(map[string]string, 0) 590 if username != "" { 591 data[client.GitUsernameSecretName] = username 592 } 593 594 if token != "" { 595 data[client.GitTokenSecretName] = token 596 } 597 598 return data 599 } 600 601 func updateTestSecretsData(data map[string]string, username, token *string) map[string]string { 602 if data == nil { 603 data = make(map[string]string) 604 } 605 606 if username != nil { 607 if *username == "" { 608 delete(data, client.GitUsernameSecretName) 609 } else { 610 data[client.GitUsernameSecretName] = *username 611 } 612 } 613 614 if token != nil { 615 if *token == "" { 616 delete(data, client.GitTokenSecretName) 617 } else { 618 data[client.GitTokenSecretName] = *token 619 } 620 } 621 622 return data 623 }