github.com/kubeshop/testkube@v1.17.23/pkg/tcl/repositorytcl/testworkflow/mongo.go (about)

     1  // Copyright 2024 Testkube.
     2  //
     3  // Licensed as a Testkube Pro file under the Testkube Community
     4  // License (the "License"); you may not use this file except in compliance with
     5  // the License. You may obtain a copy of the License at
     6  //
     7  //	https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt
     8  
     9  package testworkflow
    10  
    11  import (
    12  	"context"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/kubeshop/testkube/pkg/repository/common"
    17  
    18  	"go.mongodb.org/mongo-driver/bson"
    19  	"go.mongodb.org/mongo-driver/bson/primitive"
    20  	"go.mongodb.org/mongo-driver/mongo"
    21  	"go.mongodb.org/mongo-driver/mongo/options"
    22  
    23  	"github.com/kubeshop/testkube/pkg/api/v1/testkube"
    24  )
    25  
    26  var _ Repository = (*MongoRepository)(nil)
    27  
    28  const CollectionName = "testworkflowresults"
    29  
    30  func NewMongoRepository(db *mongo.Database, allowDiskUse bool, opts ...MongoRepositoryOpt) *MongoRepository {
    31  	r := &MongoRepository{
    32  		db:           db,
    33  		Coll:         db.Collection(CollectionName),
    34  		allowDiskUse: allowDiskUse,
    35  	}
    36  
    37  	for _, opt := range opts {
    38  		opt(r)
    39  	}
    40  
    41  	return r
    42  }
    43  
    44  type MongoRepository struct {
    45  	db           *mongo.Database
    46  	Coll         *mongo.Collection
    47  	allowDiskUse bool
    48  }
    49  
    50  func WithMongoRepositoryCollection(collection *mongo.Collection) MongoRepositoryOpt {
    51  	return func(r *MongoRepository) {
    52  		r.Coll = collection
    53  	}
    54  }
    55  
    56  type MongoRepositoryOpt func(*MongoRepository)
    57  
    58  func (r *MongoRepository) Get(ctx context.Context, id string) (result testkube.TestWorkflowExecution, err error) {
    59  	err = r.Coll.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result)
    60  	return *result.UnscapeDots(), err
    61  }
    62  
    63  func (r *MongoRepository) GetByNameAndTestWorkflow(ctx context.Context, name, workflowName string) (result testkube.TestWorkflowExecution, err error) {
    64  	err = r.Coll.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": name}, bson.M{"name": name}}, "workflow.name": workflowName}).Decode(&result)
    65  	return *result.UnscapeDots(), err
    66  }
    67  
    68  func (r *MongoRepository) GetLatestByTestWorkflow(ctx context.Context, workflowName string) (*testkube.TestWorkflowExecution, error) {
    69  	opts := options.Aggregate()
    70  	pipeline := []bson.M{
    71  		{"$sort": bson.M{"statusat": -1}},
    72  		{"$match": bson.M{"workflow.name": workflowName}},
    73  		{"$limit": 1},
    74  	}
    75  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
    76  	if err != nil {
    77  		return nil, err
    78  	}
    79  	var items []testkube.TestWorkflowExecution
    80  	err = cursor.All(ctx, &items)
    81  	if err != nil {
    82  		return nil, err
    83  	}
    84  	if len(items) == 0 {
    85  		return nil, mongo.ErrNoDocuments
    86  	}
    87  	return items[0].UnscapeDots(), err
    88  }
    89  
    90  func (r *MongoRepository) GetLatestByTestWorkflows(ctx context.Context, workflowNames []string) (executions []testkube.TestWorkflowExecutionSummary, err error) {
    91  	if len(workflowNames) == 0 {
    92  		return executions, nil
    93  	}
    94  
    95  	documents := bson.A{}
    96  	for _, workflowName := range workflowNames {
    97  		documents = append(documents, bson.M{"workflow.name": workflowName})
    98  	}
    99  
   100  	pipeline := []bson.M{
   101  		{"$sort": bson.M{"statusat": -1}},
   102  		{"$project": bson.M{
   103  			"_id":                   0,
   104  			"output":                0,
   105  			"signature":             0,
   106  			"result.steps":          0,
   107  			"result.initialization": 0,
   108  			"workflow.spec":         0,
   109  			"resolvedWorkflow":      0,
   110  		}},
   111  		{"$match": bson.M{"$or": documents}},
   112  		{"$group": bson.M{"_id": "$workflow.name", "execution": bson.M{"$first": "$$ROOT"}}},
   113  		{"$replaceRoot": bson.M{"newRoot": "$execution"}},
   114  	}
   115  
   116  	opts := options.Aggregate()
   117  	if r.allowDiskUse {
   118  		opts.SetAllowDiskUse(r.allowDiskUse)
   119  	}
   120  
   121  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   122  	if err != nil {
   123  		return nil, err
   124  	}
   125  	err = cursor.All(ctx, &executions)
   126  	if err != nil {
   127  		return nil, err
   128  	}
   129  
   130  	if len(executions) == 0 {
   131  		return executions, nil
   132  	}
   133  
   134  	for i := range executions {
   135  		executions[i].UnscapeDots()
   136  	}
   137  	return executions, nil
   138  }
   139  
   140  // TODO: Add limit?
   141  func (r *MongoRepository) GetRunning(ctx context.Context) (result []testkube.TestWorkflowExecution, err error) {
   142  	result = make([]testkube.TestWorkflowExecution, 0)
   143  	opts := &options.FindOptions{}
   144  	opts.SetSort(bson.D{{Key: "_id", Value: -1}})
   145  	if r.allowDiskUse {
   146  		opts.SetAllowDiskUse(r.allowDiskUse)
   147  	}
   148  
   149  	cursor, err := r.Coll.Find(ctx, bson.M{
   150  		"$or": bson.A{
   151  			bson.M{"result.status": testkube.RUNNING_TestWorkflowStatus},
   152  			bson.M{"result.status": testkube.QUEUED_TestWorkflowStatus},
   153  		},
   154  	}, opts)
   155  	if err != nil {
   156  		return result, err
   157  	}
   158  	err = cursor.All(ctx, &result)
   159  
   160  	for i := range result {
   161  		result[i].UnscapeDots()
   162  	}
   163  	return
   164  }
   165  
   166  func (r *MongoRepository) GetExecutionsTotals(ctx context.Context, filter ...Filter) (totals testkube.ExecutionsTotals, err error) {
   167  	var result []struct {
   168  		Status string `bson:"_id"`
   169  		Count  int    `bson:"count"`
   170  	}
   171  
   172  	query := bson.M{}
   173  	if len(filter) > 0 {
   174  		query, _ = composeQueryAndOpts(filter[0])
   175  	}
   176  
   177  	pipeline := []bson.D{{{Key: "$match", Value: query}}}
   178  	if len(filter) > 0 {
   179  		pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "statusat", Value: -1}}}})
   180  		pipeline = append(pipeline, bson.D{{Key: "$skip", Value: int64(filter[0].Page() * filter[0].PageSize())}})
   181  		pipeline = append(pipeline, bson.D{{Key: "$limit", Value: int64(filter[0].PageSize())}})
   182  	}
   183  
   184  	pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$result.status"},
   185  		{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}})
   186  
   187  	opts := options.Aggregate()
   188  	if r.allowDiskUse {
   189  		opts.SetAllowDiskUse(r.allowDiskUse)
   190  	}
   191  
   192  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   193  	if err != nil {
   194  		return totals, err
   195  	}
   196  	err = cursor.All(ctx, &result)
   197  	if err != nil {
   198  		return totals, err
   199  	}
   200  
   201  	var sum int32
   202  
   203  	for _, o := range result {
   204  		sum += int32(o.Count)
   205  		switch testkube.TestWorkflowStatus(o.Status) {
   206  		case testkube.QUEUED_TestWorkflowStatus:
   207  			totals.Queued = int32(o.Count)
   208  		case testkube.RUNNING_TestWorkflowStatus:
   209  			totals.Running = int32(o.Count)
   210  		case testkube.PASSED_TestWorkflowStatus:
   211  			totals.Passed = int32(o.Count)
   212  		case testkube.FAILED_TestWorkflowStatus, testkube.ABORTED_TestWorkflowStatus:
   213  			totals.Failed = int32(o.Count)
   214  		}
   215  	}
   216  	totals.Results = sum
   217  
   218  	return
   219  }
   220  
   221  func (r *MongoRepository) GetExecutions(ctx context.Context, filter Filter) (result []testkube.TestWorkflowExecution, err error) {
   222  	result = make([]testkube.TestWorkflowExecution, 0)
   223  	query, opts := composeQueryAndOpts(filter)
   224  	if r.allowDiskUse {
   225  		opts.SetAllowDiskUse(r.allowDiskUse)
   226  	}
   227  
   228  	cursor, err := r.Coll.Find(ctx, query, opts)
   229  	if err != nil {
   230  		return
   231  	}
   232  	err = cursor.All(ctx, &result)
   233  
   234  	for i := range result {
   235  		result[i].UnscapeDots()
   236  	}
   237  	return
   238  }
   239  
   240  func (r *MongoRepository) GetExecutionsSummary(ctx context.Context, filter Filter) (result []testkube.TestWorkflowExecutionSummary, err error) {
   241  	result = make([]testkube.TestWorkflowExecutionSummary, 0)
   242  	query, opts := composeQueryAndOpts(filter)
   243  	if r.allowDiskUse {
   244  		opts.SetAllowDiskUse(r.allowDiskUse)
   245  	}
   246  
   247  	opts = opts.SetProjection(bson.M{
   248  		"_id":                   0,
   249  		"output":                0,
   250  		"signature":             0,
   251  		"result.steps":          0,
   252  		"result.initialization": 0,
   253  		"workflow.spec":         0,
   254  		"resolvedWorkflow":      0,
   255  	})
   256  	cursor, err := r.Coll.Find(ctx, query, opts)
   257  	if err != nil {
   258  		return
   259  	}
   260  	err = cursor.All(ctx, &result)
   261  
   262  	for i := range result {
   263  		result[i].UnscapeDots()
   264  	}
   265  	return
   266  }
   267  
   268  func (r *MongoRepository) Insert(ctx context.Context, result testkube.TestWorkflowExecution) (err error) {
   269  	result.EscapeDots()
   270  	_, err = r.Coll.InsertOne(ctx, result)
   271  	return
   272  }
   273  
   274  func (r *MongoRepository) Update(ctx context.Context, result testkube.TestWorkflowExecution) (err error) {
   275  	result.EscapeDots()
   276  	_, err = r.Coll.ReplaceOne(ctx, bson.M{"id": result.Id}, result)
   277  	return
   278  }
   279  
   280  func (r *MongoRepository) UpdateResult(ctx context.Context, id string, result *testkube.TestWorkflowResult) (err error) {
   281  	data := bson.M{"result": result}
   282  	if !result.FinishedAt.IsZero() {
   283  		data["statusat"] = result.FinishedAt
   284  	}
   285  	_, err = r.Coll.UpdateOne(ctx, bson.M{"id": id}, bson.M{"$set": data})
   286  	return
   287  }
   288  
   289  func (r *MongoRepository) UpdateOutput(ctx context.Context, id string, refs []testkube.TestWorkflowOutput) (err error) {
   290  	_, err = r.Coll.UpdateOne(ctx, bson.M{"id": id}, bson.M{"$set": bson.M{"output": refs}})
   291  	return
   292  }
   293  
   294  func composeQueryAndOpts(filter Filter) (bson.M, *options.FindOptions) {
   295  	query := bson.M{}
   296  	opts := options.Find()
   297  	startTimeQuery := bson.M{}
   298  
   299  	if filter.NameDefined() {
   300  		query["workflow.name"] = filter.Name()
   301  	}
   302  
   303  	if filter.TextSearchDefined() {
   304  		query["name"] = bson.M{"$regex": primitive.Regex{Pattern: filter.TextSearch(), Options: "i"}}
   305  	}
   306  
   307  	if filter.LastNDaysDefined() {
   308  		startTimeQuery["$gte"] = time.Now().Add(-time.Duration(filter.LastNDays()) * 24 * time.Hour)
   309  	}
   310  
   311  	if filter.StartDateDefined() {
   312  		startTimeQuery["$gte"] = filter.StartDate()
   313  	}
   314  
   315  	if filter.EndDateDefined() {
   316  		startTimeQuery["$lte"] = filter.EndDate()
   317  	}
   318  
   319  	if len(startTimeQuery) > 0 {
   320  		query["scheduledat"] = startTimeQuery
   321  	}
   322  
   323  	if filter.StatusesDefined() {
   324  		statuses := filter.Statuses()
   325  		if len(statuses) == 1 {
   326  			query["result.status"] = statuses[0]
   327  		} else {
   328  			var conditions bson.A
   329  			for _, status := range statuses {
   330  				conditions = append(conditions, bson.M{"result.status": status})
   331  			}
   332  
   333  			query["$or"] = conditions
   334  		}
   335  	}
   336  
   337  	if filter.Selector() != "" {
   338  		items := strings.Split(filter.Selector(), ",")
   339  		for _, item := range items {
   340  			elements := strings.Split(item, "=")
   341  			if len(elements) == 2 {
   342  				query["workflow.labels."+elements[0]] = elements[1]
   343  			} else if len(elements) == 1 {
   344  				query["workflow.labels."+elements[0]] = bson.M{"$exists": true}
   345  			}
   346  		}
   347  	}
   348  
   349  	opts.SetSkip(int64(filter.Page() * filter.PageSize()))
   350  	opts.SetLimit(int64(filter.PageSize()))
   351  	opts.SetSort(bson.D{{Key: "scheduledat", Value: -1}})
   352  
   353  	return query, opts
   354  }
   355  
   356  // DeleteByTestWorkflow deletes execution results by workflow
   357  func (r *MongoRepository) DeleteByTestWorkflow(ctx context.Context, workflowName string) (err error) {
   358  	_, err = r.Coll.DeleteMany(ctx, bson.M{"workflow.name": workflowName})
   359  	return
   360  }
   361  
   362  // DeleteAll deletes all execution results
   363  func (r *MongoRepository) DeleteAll(ctx context.Context) (err error) {
   364  	_, err = r.Coll.DeleteMany(ctx, bson.M{})
   365  	return
   366  }
   367  
   368  // DeleteByTestWorkflows deletes execution results by workflows
   369  func (r *MongoRepository) DeleteByTestWorkflows(ctx context.Context, workflowNames []string) (err error) {
   370  	if len(workflowNames) == 0 {
   371  		return nil
   372  	}
   373  
   374  	conditions := bson.A{}
   375  	for _, workflowName := range workflowNames {
   376  		conditions = append(conditions, bson.M{"workflow.name": workflowName})
   377  	}
   378  
   379  	filter := bson.M{"$or": conditions}
   380  
   381  	_, err = r.Coll.DeleteMany(ctx, filter)
   382  	return
   383  }
   384  
   385  // TODO: Avoid calculating for all executions in memory (same for tests/test suites)
   386  // GetTestWorkflowMetrics returns test executions metrics
   387  func (r *MongoRepository) GetTestWorkflowMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) {
   388  	query := bson.M{"workflow.name": name}
   389  
   390  	if last > 0 {
   391  		query["scheduledat"] = bson.M{"$gte": time.Now().Add(-time.Duration(last) * 24 * time.Hour)}
   392  	}
   393  
   394  	pipeline := []bson.M{
   395  		{"$sort": bson.M{"scheduledat": -1}},
   396  		{"$match": query},
   397  		{"$project": bson.M{
   398  			"status":    "$result.status",
   399  			"duration":  "$result.duration",
   400  			"starttime": "$scheduledat",
   401  			"name":      1,
   402  		}},
   403  	}
   404  
   405  	opts := options.Aggregate()
   406  	if r.allowDiskUse {
   407  		opts.SetAllowDiskUse(r.allowDiskUse)
   408  	}
   409  
   410  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   411  	if err != nil {
   412  		return metrics, err
   413  	}
   414  
   415  	var executions []testkube.ExecutionsMetricsExecutions
   416  	err = cursor.All(ctx, &executions)
   417  
   418  	if err != nil {
   419  		return metrics, err
   420  	}
   421  
   422  	metrics = common.CalculateMetrics(executions)
   423  	if limit > 0 && limit < len(metrics.Executions) {
   424  		metrics.Executions = metrics.Executions[:limit]
   425  	}
   426  
   427  	return metrics, nil
   428  }