github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/ci/run_tests.go (about)

     1  package ci
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/quickfeed/quickfeed/internal/fileop"
    12  	"github.com/quickfeed/quickfeed/internal/qlog"
    13  	"github.com/quickfeed/quickfeed/internal/rand"
    14  	"github.com/quickfeed/quickfeed/kit/score"
    15  	"github.com/quickfeed/quickfeed/qf"
    16  	"github.com/quickfeed/quickfeed/scm"
    17  	"go.uber.org/zap"
    18  )
    19  
    20  // pattern to prefix the tmp folder for quickfeed tests
    21  const quickfeedTestsPath = "quickfeed-tests"
    22  
    23  // RunData stores CI data
    24  type RunData struct {
    25  	Course     *qf.Course
    26  	Assignment *qf.Assignment
    27  	Repo       *qf.Repository
    28  	EnvVarsFn  func(secret, homeDir string) []string
    29  	BranchName string
    30  	CommitID   string
    31  	JobOwner   string
    32  	Rebuild    bool
    33  }
    34  
    35  // String returns a string representation of the run data structure.
    36  func (r RunData) String() string {
    37  	commitID := r.CommitID
    38  	if len(commitID) > 7 {
    39  		commitID = r.CommitID[:6]
    40  	}
    41  	return fmt.Sprintf("%s-%s-%s-%s", strings.ToLower(r.Course.GetCode()), r.Assignment.GetName(), r.JobOwner, commitID)
    42  }
    43  
    44  // RunTests runs the tests for the assignment specified in the provided RunData structure,
    45  // and returns the score results or an error.
    46  // The method is idempotent and can be called concurrently on multiple RunData objects.
    47  // The method clones the student or group repository from GitHub as specified in RunData,
    48  // and copies the course's tests and assignment repositories from the host machine's file system.
    49  // runs the tests and returns the score results.
    50  //
    51  // The os.MkdirTemp() function ensures that any concurrent calls to this method will always
    52  // use distinct temp directories. Specifically, the method creates a temporary directory on
    53  // the host machine running the quickfeed server that holds the cloned/copied repositories
    54  // and will be mounted as '/quickfeed' inside the container. This allows the docker container
    55  // to run the tests on the student code and manipulate the folders as needed for a particular
    56  // lab assignment's test requirements. The temporary directory is deleted when the container
    57  // exits at the end of this method.
    58  func (r *RunData) RunTests(ctx context.Context, logger *zap.SugaredLogger, sc scm.SCM, runner Runner) (*score.Results, error) {
    59  	testsStartedCounter.WithLabelValues(r.JobOwner, r.Course.Code).Inc()
    60  
    61  	dstDir, err := os.MkdirTemp("", quickfeedTestsPath)
    62  	if err != nil {
    63  		return nil, err
    64  	}
    65  	defer os.RemoveAll(dstDir)
    66  
    67  	logger.Debugf("Cloning repository for %s", r)
    68  	if err = r.clone(ctx, sc, dstDir); err != nil {
    69  		return nil, err
    70  	}
    71  	logger.Debugf("Successfully cloned student repository to: %s", dstDir)
    72  
    73  	if err := scanStudentRepo(filepath.Join(dstDir, r.Repo.Name()), r.Course.GetCode(), r.JobOwner); err != nil {
    74  		return nil, err
    75  	}
    76  
    77  	randomSecret := rand.String()
    78  	job, err := r.parseTestRunnerScript(randomSecret, dstDir)
    79  	if err != nil {
    80  		return nil, fmt.Errorf("failed to parse run script for assignment %s in %s: %w", r.Assignment.GetName(), r.Repo.GetTestURL(), err)
    81  	}
    82  
    83  	defer timer(r.JobOwner, r.Course.Code, testExecutionTimeGauge)()
    84  	logger.Debugf("Running tests for %s", r)
    85  	start := time.Now()
    86  	out, err := runner.Run(ctx, job)
    87  	if err != nil && out == "" {
    88  		testsFailedCounter.WithLabelValues(r.JobOwner, r.Course.Code).Inc()
    89  		return nil, fmt.Errorf("test execution failed without output: %w", err)
    90  	}
    91  	if err != nil {
    92  		// We may reach here with a timeout error and a non-empty output
    93  		testsFailedWithOutputCounter.WithLabelValues(r.JobOwner, r.Course.Code).Inc()
    94  		logger.Errorf("Test execution failed with output: %v\n%v", err, out)
    95  	}
    96  
    97  	results, err := score.ExtractResults(out, randomSecret, time.Since(start))
    98  	if err != nil {
    99  		// Log the errors from the extraction process
   100  		testsFailedExtractResultsCounter.WithLabelValues(r.JobOwner, r.Course.Code).Inc()
   101  		logger.Debugf("Session secret: %s", randomSecret)
   102  		logger.Errorf("Failed to extract (some) results for assignment %s for course %s: %v", r.Assignment.Name, r.Course.Name, err)
   103  		// don't return here; we still want partial results!
   104  	}
   105  
   106  	testsSucceededCounter.WithLabelValues(r.JobOwner, r.Course.Code).Inc()
   107  	logger.Debug("ci.RunTests", zap.Any("Results", qlog.IndentJson(results)))
   108  	// return the extracted score and filtered log output
   109  	return results, nil
   110  }
   111  
   112  func (r RunData) clone(ctx context.Context, sc scm.SCM, dstDir string) error {
   113  	defer timer(r.JobOwner, r.Course.GetCode(), cloneTimeGauge)()
   114  
   115  	clonedStudentRepo, err := sc.Clone(ctx, &scm.CloneOptions{
   116  		Organization: r.Course.GetScmOrganizationName(),
   117  		Repository:   r.Repo.Name(),
   118  		DestDir:      dstDir,
   119  		Branch:       r.BranchName,
   120  	})
   121  	if err != nil {
   122  		return fmt.Errorf("failed to clone %s/%s repository: %w", r.Course.GetScmOrganizationName(), r.Repo.Name(), err)
   123  	}
   124  
   125  	// Clone the course's tests and assignments repositories if they are missing.
   126  	// Cloning is only needed when the quickfeed server has not yet received a push event
   127  	// for a course's tests or assignments repositories or an UpdateAssignment request.
   128  	if err := cloneMissingRepositories(ctx, sc, r.Course); err != nil {
   129  		return err
   130  	}
   131  
   132  	// Check that all repositories contains the current assignment
   133  	currentAssignment := r.Assignment.GetName()
   134  	testsDir := filepath.Join(r.Course.CloneDir(), qf.TestsRepo)
   135  	assignmentDir := filepath.Join(r.Course.CloneDir(), qf.AssignmentsRepo)
   136  	for _, repoDir := range []string{clonedStudentRepo, testsDir, assignmentDir} {
   137  		if err := hasAssignment(repoDir, currentAssignment); err != nil {
   138  			return err
   139  		}
   140  	}
   141  	// Copy the tests and assignment repos to the destination directory
   142  	if err = fileop.CopyDir(testsDir, dstDir); err != nil {
   143  		return err
   144  	}
   145  	return fileop.CopyDir(assignmentDir, dstDir)
   146  }