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 }