github.com/kubeshop/testkube@v1.17.23/pkg/repository/testresult/mongo.go (about) 1 package testresult 2 3 import ( 4 "context" 5 "strings" 6 "time" 7 8 "github.com/kubeshop/testkube/pkg/repository/common" 9 10 "go.mongodb.org/mongo-driver/bson" 11 "go.mongodb.org/mongo-driver/bson/primitive" 12 "go.mongodb.org/mongo-driver/mongo" 13 "go.mongodb.org/mongo-driver/mongo/options" 14 15 "github.com/kubeshop/testkube/pkg/api/v1/testkube" 16 ) 17 18 var _ Repository = (*MongoRepository)(nil) 19 20 const CollectionName = "testresults" 21 22 func NewMongoRepository(db *mongo.Database, allowDiskUse, isDocDb bool, opts ...MongoRepositoryOpt) *MongoRepository { 23 r := &MongoRepository{ 24 db: db, 25 Coll: db.Collection(CollectionName), 26 allowDiskUse: allowDiskUse, 27 isDocDb: isDocDb, 28 } 29 30 for _, opt := range opts { 31 opt(r) 32 } 33 34 return r 35 } 36 37 type MongoRepository struct { 38 db *mongo.Database 39 Coll *mongo.Collection 40 allowDiskUse bool 41 isDocDb bool 42 } 43 44 func WithMongoRepositoryCollection(collection *mongo.Collection) MongoRepositoryOpt { 45 return func(r *MongoRepository) { 46 r.Coll = collection 47 } 48 } 49 50 type MongoRepositoryOpt func(*MongoRepository) 51 52 func (r *MongoRepository) Get(ctx context.Context, id string) (result testkube.TestSuiteExecution, err error) { 53 err = r.Coll.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result) 54 return *result.UnscapeDots(), err 55 } 56 57 func (r *MongoRepository) GetByNameAndTestSuite(ctx context.Context, name, testSuiteName string) (result testkube.TestSuiteExecution, err error) { 58 err = r.Coll.FindOne(ctx, bson.M{"name": name, "testsuite.name": testSuiteName}).Decode(&result) 59 return *result.UnscapeDots(), err 60 } 61 62 func (r *MongoRepository) slowGetLatestByTestSuite(ctx context.Context, testSuiteName string) (*testkube.TestSuiteExecution, error) { 63 opts := options.Aggregate() 64 pipeline := []bson.M{ 65 {"$project": bson.M{"testsuite.name": 1, "starttime": 1, "endtime": 1}}, 66 {"$match": bson.M{"testsuite.name": testSuiteName}}, 67 68 {"$addFields": bson.M{ 69 "updatetime": bson.M{"$max": bson.A{"$starttime", "$endtime"}}, 70 }}, 71 {"$group": bson.D{ 72 {Key: "_id", Value: "$testsuite.name"}, 73 {Key: "doc", Value: bson.M{"$max": bson.D{ 74 {Key: "updatetime", Value: "$updatetime"}, 75 {Key: "content", Value: "$$ROOT"}, 76 }}}, 77 }}, 78 {"$sort": bson.M{"doc.updatetime": -1}}, 79 {"$limit": 1}, 80 81 {"$lookup": bson.M{"from": r.Coll.Name(), "localField": "doc.content._id", "foreignField": "_id", "as": "execution"}}, 82 {"$replaceRoot": bson.M{"newRoot": bson.M{"$arrayElemAt": bson.A{"$execution", 0}}}}, 83 } 84 cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) 85 if err != nil { 86 return nil, err 87 } 88 var items []testkube.TestSuiteExecution 89 err = cursor.All(ctx, &items) 90 if err != nil { 91 return nil, err 92 } 93 if len(items) == 0 { 94 return nil, mongo.ErrNoDocuments 95 } 96 return items[0].UnscapeDots(), err 97 } 98 99 func (r *MongoRepository) GetLatestByTestSuite(ctx context.Context, testSuiteName string) (*testkube.TestSuiteExecution, error) { 100 // Workaround, to use subset of MongoDB features available in AWS DocumentDB 101 if r.isDocDb { 102 return r.slowGetLatestByTestSuite(ctx, testSuiteName) 103 } 104 105 opts := options.Aggregate() 106 pipeline := []bson.M{ 107 {"$group": bson.M{"_id": "$testsuite.name", "doc": bson.M{"$first": bson.M{}}}}, 108 {"$project": bson.M{"_id": 0, "name": "$_id"}}, 109 {"$match": bson.M{"name": testSuiteName}}, 110 111 {"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{ 112 {"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}}, 113 {"$sort": bson.M{"starttime": -1}}, 114 {"$limit": 1}, 115 }, "as": "execution_by_start_time"}}, 116 {"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{ 117 {"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}}, 118 {"$sort": bson.M{"endtime": -1}}, 119 {"$limit": 1}, 120 }, "as": "execution_by_end_time"}}, 121 {"$project": bson.M{"executions": bson.M{"$concatArrays": bson.A{"$execution_by_start_time", "$execution_by_end_time"}}}}, 122 {"$unwind": "$executions"}, 123 {"$replaceRoot": bson.M{"newRoot": "$executions"}}, 124 125 {"$group": bson.D{ 126 {Key: "_id", Value: "$testsuite.name"}, 127 {Key: "doc", Value: bson.M{"$max": bson.D{ 128 {Key: "updatetime", Value: bson.M{"$max": bson.A{"$starttime", "$endtime"}}}, 129 {Key: "content", Value: "$$ROOT"}, 130 }}}, 131 }}, 132 {"$sort": bson.M{"doc.updatetime": -1}}, 133 {"$replaceRoot": bson.M{"newRoot": "$doc.content"}}, 134 {"$limit": 1}, 135 } 136 cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) 137 if err != nil { 138 return nil, err 139 } 140 var items []testkube.TestSuiteExecution 141 err = cursor.All(ctx, &items) 142 if err != nil { 143 return nil, err 144 } 145 if len(items) == 0 { 146 return nil, mongo.ErrNoDocuments 147 } 148 return items[0].UnscapeDots(), err 149 } 150 151 func (r *MongoRepository) slowGetLatestByTestSuites(ctx context.Context, testSuiteNames []string) (executions []testkube.TestSuiteExecution, err error) { 152 documents := bson.A{} 153 for _, testSuiteName := range testSuiteNames { 154 documents = append(documents, bson.M{"testsuite.name": testSuiteName}) 155 } 156 157 pipeline := []bson.M{ 158 {"$project": bson.M{"testsuite.name": 1, "starttime": 1, "endtime": 1}}, 159 {"$match": bson.M{"$or": documents}}, 160 161 {"$addFields": bson.M{ 162 "updatetime": bson.M{"$max": bson.A{"$starttime", "$endtime"}}, 163 }}, 164 {"$group": bson.D{ 165 {Key: "_id", Value: "$testsuite.name"}, 166 {Key: "doc", Value: bson.M{"$max": bson.D{ 167 {Key: "updatetime", Value: "$updatetime"}, 168 {Key: "content", Value: "$$ROOT"}, 169 }}}, 170 }}, 171 {"$sort": bson.M{"doc.updatetime": -1}}, 172 173 {"$lookup": bson.M{"from": r.Coll.Name(), "localField": "doc.content._id", "foreignField": "_id", "as": "execution"}}, 174 {"$replaceRoot": bson.M{"newRoot": bson.M{"$arrayElemAt": bson.A{"$execution", 0}}}}, 175 } 176 177 opts := options.Aggregate() 178 if r.allowDiskUse { 179 opts.SetAllowDiskUse(r.allowDiskUse) 180 } 181 182 cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) 183 if err != nil { 184 return nil, err 185 } 186 err = cursor.All(ctx, &executions) 187 if err != nil { 188 return nil, err 189 } 190 191 if len(executions) == 0 { 192 return executions, nil 193 } 194 195 for i := range executions { 196 executions[i].UnscapeDots() 197 } 198 return executions, nil 199 } 200 201 func (r *MongoRepository) GetLatestByTestSuites(ctx context.Context, testSuiteNames []string) (executions []testkube.TestSuiteExecution, err error) { 202 if len(testSuiteNames) == 0 { 203 return executions, nil 204 } 205 206 // Workaround, to use subset of MongoDB features available in AWS DocumentDB 207 if r.isDocDb { 208 return r.slowGetLatestByTestSuites(ctx, testSuiteNames) 209 } 210 211 documents := bson.A{} 212 for _, testSuiteName := range testSuiteNames { 213 documents = append(documents, bson.M{"name": testSuiteName}) 214 } 215 216 pipeline := []bson.M{ 217 {"$group": bson.M{"_id": "$testsuite.name", "doc": bson.M{"$first": bson.M{}}}}, 218 {"$project": bson.M{"_id": 0, "name": "$_id"}}, 219 {"$match": bson.M{"$or": documents}}, 220 221 {"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{ 222 {"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}}, 223 {"$sort": bson.M{"starttime": -1}}, 224 {"$limit": 1}, 225 }, "as": "execution_by_start_time"}}, 226 {"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{ 227 {"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}}, 228 {"$sort": bson.M{"endtime": -1}}, 229 {"$limit": 1}, 230 }, "as": "execution_by_end_time"}}, 231 {"$project": bson.M{"executions": bson.M{"$concatArrays": bson.A{"$execution_by_start_time", "$execution_by_end_time"}}}}, 232 {"$unwind": "$executions"}, 233 {"$replaceRoot": bson.M{"newRoot": "$executions"}}, 234 235 {"$group": bson.D{ 236 {Key: "_id", Value: "$testsuite.name"}, 237 {Key: "doc", Value: bson.M{"$max": bson.D{ 238 {Key: "updatetime", Value: bson.M{"$max": bson.A{"$starttime", "$endtime"}}}, 239 {Key: "content", Value: "$$ROOT"}, 240 }}}, 241 }}, 242 {"$sort": bson.M{"doc.updatetime": -1}}, 243 {"$replaceRoot": bson.M{"newRoot": "$doc.content"}}, 244 } 245 246 opts := options.Aggregate() 247 if r.allowDiskUse { 248 opts.SetAllowDiskUse(r.allowDiskUse) 249 } 250 251 cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) 252 if err != nil { 253 return nil, err 254 } 255 err = cursor.All(ctx, &executions) 256 if err != nil { 257 return nil, err 258 } 259 260 if len(executions) == 0 { 261 return executions, nil 262 } 263 264 for i := range executions { 265 executions[i].UnscapeDots() 266 } 267 return executions, nil 268 } 269 270 func (r *MongoRepository) GetNewestExecutions(ctx context.Context, limit int) (result []testkube.TestSuiteExecution, err error) { 271 result = make([]testkube.TestSuiteExecution, 0) 272 resultLimit := int64(limit) 273 opts := &options.FindOptions{Limit: &resultLimit} 274 opts.SetSort(bson.D{{Key: "_id", Value: -1}}) 275 if r.allowDiskUse { 276 opts.SetAllowDiskUse(r.allowDiskUse) 277 } 278 279 cursor, err := r.Coll.Find(ctx, bson.M{}, opts) 280 if err != nil { 281 return result, err 282 } 283 err = cursor.All(ctx, &result) 284 285 for i := range result { 286 result[i].UnscapeDots() 287 } 288 return 289 } 290 291 func (r *MongoRepository) Count(ctx context.Context, filter Filter) (count int64, err error) { 292 query, _ := composeQueryAndOpts(filter) 293 return r.Coll.CountDocuments(ctx, query) 294 } 295 296 func (r *MongoRepository) GetExecutionsTotals(ctx context.Context, filter ...Filter) (totals testkube.ExecutionsTotals, err error) { 297 var result []struct { 298 Status string `bson:"_id"` 299 Count int `bson:"count"` 300 } 301 302 query := bson.M{} 303 if len(filter) > 0 { 304 query, _ = composeQueryAndOpts(filter[0]) 305 } 306 307 pipeline := []bson.D{ 308 {{Key: "$sort", Value: bson.M{"status": 1}}}, 309 {{Key: "$match", Value: query}}, 310 } 311 if len(filter) > 0 { 312 pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "starttime", Value: -1}}}}) 313 pipeline = append(pipeline, bson.D{{Key: "$skip", Value: int64(filter[0].Page() * filter[0].PageSize())}}) 314 pipeline = append(pipeline, bson.D{{Key: "$limit", Value: int64(filter[0].PageSize())}}) 315 } else { 316 pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "status", Value: 1}}}}) 317 } 318 319 pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$status"}, 320 {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}) 321 322 opts := options.Aggregate() 323 if r.allowDiskUse { 324 opts.SetAllowDiskUse(r.allowDiskUse) 325 } 326 327 cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) 328 if err != nil { 329 return totals, err 330 } 331 err = cursor.All(ctx, &result) 332 if err != nil { 333 return totals, err 334 } 335 336 var sum int32 337 338 for _, o := range result { 339 sum += int32(o.Count) 340 switch testkube.TestSuiteExecutionStatus(o.Status) { 341 case testkube.QUEUED_TestSuiteExecutionStatus: 342 totals.Queued = int32(o.Count) 343 case testkube.RUNNING_TestSuiteExecutionStatus: 344 totals.Running = int32(o.Count) 345 case testkube.PASSED_TestSuiteExecutionStatus: 346 totals.Passed = int32(o.Count) 347 case testkube.FAILED_TestSuiteExecutionStatus: 348 totals.Failed = int32(o.Count) 349 } 350 } 351 totals.Results = sum 352 353 return 354 } 355 356 func (r *MongoRepository) GetExecutions(ctx context.Context, filter Filter) (result []testkube.TestSuiteExecution, err error) { 357 result = make([]testkube.TestSuiteExecution, 0) 358 query, opts := composeQueryAndOpts(filter) 359 if r.allowDiskUse { 360 opts.SetAllowDiskUse(r.allowDiskUse) 361 } 362 363 cursor, err := r.Coll.Find(ctx, query, opts) 364 if err != nil { 365 return 366 } 367 err = cursor.All(ctx, &result) 368 369 for i := range result { 370 result[i].UnscapeDots() 371 } 372 return 373 } 374 375 func (r *MongoRepository) Insert(ctx context.Context, result testkube.TestSuiteExecution) (err error) { 376 result.EscapeDots() 377 result.CleanStepsOutput() 378 _, err = r.Coll.InsertOne(ctx, result) 379 return 380 } 381 382 func (r *MongoRepository) Update(ctx context.Context, result testkube.TestSuiteExecution) (err error) { 383 result.EscapeDots() 384 result.CleanStepsOutput() 385 _, err = r.Coll.ReplaceOne(ctx, bson.M{"id": result.Id}, result) 386 return 387 } 388 389 // StartExecution updates execution start time 390 func (r *MongoRepository) StartExecution(ctx context.Context, id string, startTime time.Time) (err error) { 391 _, err = r.Coll.UpdateOne(ctx, bson.M{"id": id}, bson.M{"$set": bson.M{"starttime": startTime}}) 392 return 393 } 394 395 // EndExecution updates execution end time 396 func (r *MongoRepository) EndExecution(ctx context.Context, e testkube.TestSuiteExecution) (err error) { 397 _, err = r.Coll.UpdateOne(ctx, bson.M{"id": e.Id}, bson.M{"$set": bson.M{"endtime": e.EndTime, "duration": e.Duration, "durationms": e.DurationMs}}) 398 return 399 } 400 401 func composeQueryAndOpts(filter Filter) (bson.M, *options.FindOptions) { 402 403 query := bson.M{} 404 opts := options.Find() 405 startTimeQuery := bson.M{} 406 407 if filter.NameDefined() { 408 query["testsuite.name"] = filter.Name() 409 } 410 411 if filter.TextSearchDefined() { 412 query["name"] = bson.M{"$regex": primitive.Regex{Pattern: filter.TextSearch(), Options: "i"}} 413 } 414 415 if filter.LastNDaysDefined() { 416 startTimeQuery["$gte"] = time.Now().Add(-time.Duration(filter.LastNDays()) * 24 * time.Hour) 417 } 418 419 if filter.StartDateDefined() { 420 startTimeQuery["$gte"] = filter.StartDate() 421 } 422 423 if filter.EndDateDefined() { 424 startTimeQuery["$lte"] = filter.EndDate() 425 } 426 427 if len(startTimeQuery) > 0 { 428 query["starttime"] = startTimeQuery 429 } 430 431 if filter.StatusesDefined() { 432 statuses := filter.Statuses() 433 if len(statuses) == 1 { 434 query["status"] = statuses[0] 435 } else { 436 var conditions bson.A 437 for _, status := range statuses { 438 conditions = append(conditions, bson.M{"status": status}) 439 } 440 441 query["$or"] = conditions 442 } 443 } 444 445 if filter.Selector() != "" { 446 items := strings.Split(filter.Selector(), ",") 447 for _, item := range items { 448 elements := strings.Split(item, "=") 449 if len(elements) == 2 { 450 query["labels."+elements[0]] = elements[1] 451 } else if len(elements) == 1 { 452 query["labels."+elements[0]] = bson.M{"$exists": true} 453 } 454 } 455 } 456 457 opts.SetSkip(int64(filter.Page() * filter.PageSize())) 458 opts.SetLimit(int64(filter.PageSize())) 459 opts.SetSort(bson.D{{Key: "starttime", Value: -1}}) 460 461 return query, opts 462 } 463 464 // DeleteByTestSuite deletes execution results by test suite 465 func (r *MongoRepository) DeleteByTestSuite(ctx context.Context, testSuiteName string) (err error) { 466 _, err = r.Coll.DeleteMany(ctx, bson.M{"testsuite.name": testSuiteName}) 467 return 468 } 469 470 // DeleteAll deletes all execution results 471 func (r *MongoRepository) DeleteAll(ctx context.Context) (err error) { 472 _, err = r.Coll.DeleteMany(ctx, bson.M{}) 473 return 474 } 475 476 // DeleteByTestSuites deletes execution results by test suites 477 func (r *MongoRepository) DeleteByTestSuites(ctx context.Context, testSuiteNames []string) (err error) { 478 if len(testSuiteNames) == 0 { 479 return nil 480 } 481 482 var filter bson.M 483 if len(testSuiteNames) > 1 { 484 conditions := bson.A{} 485 for _, testSuiteName := range testSuiteNames { 486 conditions = append(conditions, bson.M{"testsuite.name": testSuiteName}) 487 } 488 489 filter = bson.M{"$or": conditions} 490 } else { 491 filter = bson.M{"testsuite.name": testSuiteNames[0]} 492 } 493 494 _, err = r.Coll.DeleteMany(ctx, filter) 495 return 496 } 497 498 // GetTestSuiteMetrics returns test executions metrics 499 func (r *MongoRepository) GetTestSuiteMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) { 500 query := bson.M{"testsuite.name": name} 501 502 var pipeline []bson.D 503 if last > 0 { 504 query["starttime"] = bson.M{"$gte": time.Now().Add(-time.Duration(last) * 24 * time.Hour)} 505 } 506 507 pipeline = append(pipeline, bson.D{{Key: "$match", Value: query}}) 508 pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "starttime", Value: -1}}}}) 509 pipeline = append(pipeline, bson.D{ 510 { 511 Key: "$project", Value: bson.D{ 512 {Key: "status", Value: 1}, 513 {Key: "duration", Value: 1}, 514 {Key: "starttime", Value: 1}, 515 {Key: "name", Value: 1}, 516 }, 517 }, 518 }) 519 520 opts := options.Aggregate() 521 if r.allowDiskUse { 522 opts.SetAllowDiskUse(r.allowDiskUse) 523 } 524 525 cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) 526 if err != nil { 527 return metrics, err 528 } 529 530 var executions []testkube.ExecutionsMetricsExecutions 531 err = cursor.All(ctx, &executions) 532 533 if err != nil { 534 return metrics, err 535 } 536 537 metrics = common.CalculateMetrics(executions) 538 if limit > 0 && limit < len(metrics.Executions) { 539 metrics.Executions = metrics.Executions[:limit] 540 } 541 542 return metrics, nil 543 }