grading
Version:
Grading of student submissions, in particular programming tests.
450 lines (399 loc) • 21.3 kB
text/typescript
import { OptionValues } from 'commander';
import * as fs from 'fs';
import path from 'path';
import { hrtime } from 'process';
import { absPath, createDir, ensureFileExists, ensureFolderExists, execShell, fileExists, findDirContainingFile, folderExists, isEmptyDir, readDir, rmDir, rmFile, timestamp, unzip } from '../fsUtil';
import { getPatchFileName, gitAddAndCommit, gitApplyPatch, gitCreateAndCheckoutBranch, hasLocalGitRepository } from '../gitCommands';
import { error, generateFileName, isSubmissionSelected, log, parseSubmitter, retrieveSelectedFilter, retrieveZipFile, timeDiff, verb, verbosity, warn } from './cliUtil';
import { Submitter } from './submitter';
export async function cmdCheckAction(zipFile: string, options: OptionValues) {
return await cmdCheck(zipFile, options);
}
function appendLog(logfile: string, message?: any, ...optionalParams: any[]) {
let out = message ?? "";
if (optionalParams) {
out += " " + optionalParams.join(" ");
}
out += "\n";
fs.appendFileSync(logfile, out, { encoding: "utf-8" });
}
async function rewireConsoleOutput(logOutput: string) {
if (!logOutput) {
return;
}
const logFile = generateFileName(logOutput, {
dateTime: Date.now()
});
if (await fileExists(logFile)) {
log(`Reset log file ${logFile}`);
rmFile(logFile);
}
const parentFolder = path.dirname(logFile);
if (! await fileExists(parentFolder)) {
await createDir(parentFolder);
}
const orgConsoleLog = console.log;
const orgConsoleError = console.error;
const orgConsoleWarn = console.warn;
console.log = function (message?: any, ...optionalParams: any[]) {
orgConsoleLog(message, ...optionalParams);
appendLog(logFile, message, ...optionalParams);
}
console.error = function (message?: any, ...optionalParams: any[]) {
orgConsoleError(message, ...optionalParams);
appendLog(logFile, message, ...optionalParams);
}
console.warn = function (message?: any, ...optionalParams: any[]) {
orgConsoleWarn(message, ...optionalParams);
appendLog(logFile, message, ...optionalParams);
}
orgConsoleLog(`Write all output to ${logFile}`)
}
export async function cmdCheck(zipFile: string, options: OptionValues, runInternally = false, errors?: string[]) {
verbosity(options);
if (!errors) {
errors = [];
}
const orgConsoleLog = console.log;
const orgConsoleError = console.error;
const orgConsoleWarn = console.warn;
try {
const logOutput = options.logOutput;
await rewireConsoleOutput(logOutput);
const startTime = hrtime.bigint();
if (runInternally) {
log("Start running tests on submissions.");
} else {
log("Start running tests on submissions at " + timestamp());
}
try {
zipFile = await retrieveZipFile(zipFile, options);
await ensureFileExists(zipFile, "Check zip file.");
const submissionsDir = options.submissionsDir;
const checkDir = options.checkDir;
const clipDir = options.clipDir;
const reportsDir = options.reportsDir;
const npmCacheDir = options.npmCacheDir;
const cacheDir = options.cacheDir;
const dry = options.dry;
const prepareSubmissionCmd = parsePrepareSubmissionCmd(options.prepareSubmissionCmd);
const onlyPrepareSubmission = options.onlyPrepareSubmission;
const skipPrepareSubmission = options.skipPrepareSubmission;
const volumes = options.volumes;
const skipStudentTests = options.skipStudentTests;
const patch: boolean = options.patch;
const patchFolder: string = options.patchFolder;
const patchGradingBranch: string = options.patchGradingBranch;
if (onlyPrepareSubmission && skipPrepareSubmission) {
program.error(`--skipPrepareSubmission and --onlyPrepareSubmission are mutually exclusive.`);
}
if (onlyPrepareSubmission && !prepareSubmissionCmd) {
warn(`--onlyPrepareSubmission is set, but no prepareSubmissionCmd is defined. Nothing to do.`);
}
const selected = retrieveSelectedFilter(options);
const selectPatched = options.selectPatched;
const patchedSubmissions: Submitter[] = [];
const cleanBefore = options.cleanBefore;
if (!cleanBefore) {
verb('Do not clean before.');
}
const max = Number.parseInt(options.max);
const fromSubmission = Number.parseInt(options.fromSubmission);
const toSubmission = Number.parseInt(options.toSubmission);
await ensureFolderExists(checkDir, "Check check folder.");
if (npmCacheDir && ! await folderExists(npmCacheDir, 'npmCacheDir')) await createDir(npmCacheDir);
if (cacheDir && ! await folderExists(cacheDir, 'cacheDir')) await createDir(cacheDir);
if (clipDir && ! await folderExists(clipDir, 'clipDir')) await createDir(clipDir);
if (cleanBefore && await folderExists(submissionsDir, 'submissionsDir')) {
verb(`Remove submissions folder ${submissionsDir} (clean).`);
await rmDir(submissionsDir);
}
if (! await folderExists(submissionsDir, 'folderExists')) await createDir(submissionsDir);
if (cleanBefore && !dry && !skipStudentTests && await folderExists(reportsDir, 'reportsDir')) await rmDir(reportsDir); // don't clear on dry run
if (! await folderExists(reportsDir, 'reportsDir')) await createDir(reportsDir);
await unzip(zipFile, submissionsDir); // we always unzip the big file
const submissionDirs = await readDir(submissionsDir, 'dir', false, 'Check submissions folder.');
if (submissionDirs.length == 0) {
program.error(`No submissions found in ${submissionsDir}`)
}
let name = "", subId = ""; // define here for catch block
// toSubmission and fromSubmission are 1-based
// toSubmissionChecked is exclusive
let toSubmissionChecked = toSubmission > 0 ? Math.min(toSubmission, submissionDirs.length) : submissionDirs.length;
// fromSubmissionChecked is inclusive
let fromSubmissionChecked = fromSubmission > 0 ? Math.min(fromSubmission - 1, submissionDirs.length - 1) : 0;
// if to/from not specified: submissionDirs.length-0 = submissionDirs.length
let countFromToTo = toSubmissionChecked - fromSubmissionChecked;
let maxCheckedSubmissions = max > 0 ? Math.min(max, countFromToTo) : countFromToTo;
if (selected.length > 0 && max > 0) {
log(`Filter submissions with ids ${selected.join(', ')}, ignore max value.`);
maxCheckedSubmissions = submissionDirs.length;
}
if (selectPatched) {
if (selected.length > 0) {
warn(`--selectPatched and --selected are mutually exclusive, ignoring --selectPatched.`);
} else {
const patchFiles = await readDir(patchFolder, 'file', false, 'Check patch folder.');
patchedSubmissions.push(... patchFiles.map(f => {
const n_s = f.substring(0, f.lastIndexOf('.')).split('_');
return { name: n_s[0], submissionId: n_s[1] };
}));
verb(`Found ${patchFiles.length} patch files: ${patchedSubmissions.map(s=>s.name).join(', ')}.`)
}
}
verb(`Found ${submissionDirs.length} submissions, process from ${fromSubmissionChecked} to ${toSubmissionChecked} (max. ${maxCheckedSubmissions}).`);
for (let counter = fromSubmissionChecked; counter < fromSubmissionChecked + maxCheckedSubmissions; counter++) {
try {
const subDir = submissionDirs[counter];
({ name, submissionId: subId } = parseSubmitter(subDir));
if (!isSubmissionSelected(selected, patchedSubmissions, name, subId)) {
verb(`Skip user ${name}, submission id ${subId}`);
continue;
}
const absSubDir = path.join(submissionsDir, subDir);
log(SEP);
log(`\nProcessing submission ${counter + 1} of ${submissionDirs.length}: ${subId} ${name}\n`, options.colorSection);
// find the submitted zip file:
let submittedFiles = await readDir(absSubDir, 'file');
if (submittedFiles.length != 1) {
log(`Warning: ${name} submitted ${submittedFiles.length} files`);
if (submittedFiles.length > 1) {
submittedFiles = submittedFiles.filter(f => f.endsWith('.zip'));
if (submittedFiles.length != 1) {
log(`Warning: ${name} submitted ${submittedFiles.length} zip-files, take first one.`);
}
submittedFiles = [submittedFiles[0]]
}
if (submittedFiles.length != 1) {
log(`Warning: Skipping submission of ${name}.`);
continue;
}
}
// unzip the submitted zip file
const wsDir = path.join(submissionsDir, subDir, 'ws' + (name ? "_" + name.replace(/ /g, '_') : ""));
if (!skipPrepareSubmission) {
if (await folderExists(wsDir)) {
log(`Remove workspace folder ${wsDir}.`)
await rmDir(wsDir);
}
await createDir(wsDir);
const subZip = path.join(absSubDir, submittedFiles[0])
await unzip(subZip, wsDir);
} else {
if (! await folderExists(wsDir, 'workspace folder')) {
warn(`Skipping submission of ${name}, workspace folder not unzipped yet and skipPrepareSubmission is set.`);
continue;
}
}
// find project folder, usually the one folder contained in the submission
const projectDir = await findDirContainingFile(wsDir, options.projectFile, options.ignoredDirs);
if (prepareSubmissionCmd && !skipPrepareSubmission) {
// usually git reset --hard
await execShell(prepareSubmissionCmd.cmd, prepareSubmissionCmd.args,
{ ...options, colored: options.colored || false, colorStandard: options.colorPre }, { indent: false, prefix: 'pre: ', cwd: projectDir });
if (patch && await hasLocalGitRepository(projectDir)) {
const patchFileName = getPatchFileName(patchFolder, name, subId);
if (await fileExists(patchFileName, 'patch file')) {
if (dry) {
log(`Dry run: Would checkout new branch ${patchGradingBranch}, apply patch (and commit), and work on that branch.`);
} else {
log(`Patch file ${patchFileName} exists, will checkout new branch ${patchGradingBranch}, apply patch (and commit), and work on that branch.`);
try {
await gitCreateAndCheckoutBranch(projectDir, patchGradingBranch, { colored: options.colored || false });
await gitApplyPatch(projectDir, patchFileName, { colored: options.colored || false });
await gitAddAndCommit(projectDir, `Applied lecturer's patch`, { colored: options.colored || false });
} catch (err) {
warn(`Error during patching: ${err}`);
}
}
}
}
}
if (!onlyPrepareSubmission) {
const clipUserDir = await createClipSubmissionDir(clipDir, subId);
await runPreCheckScript(options.preCheckScript, projectDir, clipUserDir, checkDir, false, options);
try {
await runDocker("check", skipStudentTests, checkDir, projectDir, reportsDir, npmCacheDir, cacheDir, clipUserDir, volumes, subId,
{
dry: options.dry, dockerImage: options.dockerImage,
dockerUserDir: options.dockerUserDir,
dockerWorkDir: options.dockerWorkDir,
dockerShellCmd: options.dockerShellCmd, dockerArgs: options.dockerArgs,
dockerScript: options.dockerScript, timeoutPerDockerRun: options.timeoutPerDockerRun,
preserveContainer: false
}, options
);
} finally {
await runPostCheckScript(options.postCheckScript, projectDir, clipUserDir, checkDir, false, options);
if (clipUserDir) {
await rmClipSubmissionDir(clipUserDir, subId);
}
}
}
} catch (err) {
const msg = `${name}: ${err instanceof Error ? err.message : err}`
error(`${msg}`)
error(`Continue with next submission.`)
errors.push(msg);
}
}
if (submissionDirs.length > maxCheckedSubmissions) {
log(`Maximal submissions reached, skipping ${submissionDirs.length - maxCheckedSubmissions} submissions.`)
}
} catch (err) {
error(`${SEP}`);
if (errors.length > 0) {
error(`There were ${errors.length} errors:`)
errors.forEach(msg => error(msg));
}
program.error(String(err));
}
const endTime = hrtime.bigint();
const duration = timeDiff(startTime, endTime)
if (!runInternally) {
log(`${SEP}\nDone, needed ${duration} minutes.`);
if (errors.length > 0) {
error(`There were ${errors.length} errors:`)
errors.forEach(msg => error(msg));
}
}
} finally {
console.log = orgConsoleLog;
console.error = orgConsoleError;
console.warn = orgConsoleWarn;
}
}
type PhaseType = "prepare" | "check";
export async function runDocker(phase: PhaseType, skipStudentTests: boolean, checkDir: string, projectDir: string,
reportsDir: string, npmCacheDir: string, cacheDir: string, clipUserDir: string | undefined, volumes: string[], submissionId: string,
optionsForDocker: {
dry: boolean, dockerImage: string,
dockerUserDir: string,
dockerWorkDir: string,
dockerScript: string, dockerArgs: string[]
timeoutPerDockerRun: number, dockerShellCmd: string
preserveContainer: boolean
}, options: OptionValues) {
const dockerArgs = [`run`]
if (!optionsForDocker.preserveContainer) dockerArgs.push(`--rm`);
dockerArgs.push(
`-v`, `${absPath(checkDir)}/:/check`,
`-v`, `${absPath(projectDir)}/:/testee`,
`-v`, `${absPath(reportsDir)}/:/reports`
);
let userDir = optionsForDocker.dockerUserDir;
if (!userDir.startsWith('/')) userDir = '/' + userDir;
if (!userDir.endsWith('/')) userDir = userDir + '/';
let workDir = optionsForDocker.dockerWorkDir;
if (!workDir.startsWith('/')) workDir = '/' + workDir;
if (npmCacheDir) dockerArgs.push(`-v`, `${absPath(npmCacheDir)}/:${userDir}.npm`);
if (cacheDir) dockerArgs.push(`-v`, `${absPath(cacheDir)}/:${userDir}.cache`);
if (clipUserDir) dockerArgs.push(`-v`, `${absPath(clipUserDir)}/:/clip`);
if (volumes) {
await Promise.all(
volumes.map(async (v) => {
const parts = v.split(':');
if (parts.length != 2) {
program.error(`Invalid volume ${v}, must be hostDir:containerDir. It must contain on colon only!`);
}
let [host, container] = parts;
if (!host.startsWith('/')) {
const relHost = host;
host = path.resolve(host);
verb(`Made mounted folder absolute: ${relHost} --> ${host}`)
await createDir(host);
}
verb(`Add volume ${host}:${container}`);
dockerArgs.push(`-v`, `${host}:${container}`);
}))
};
if (optionsForDocker.dockerArgs) dockerArgs.push(...optionsForDocker.dockerArgs);
dockerArgs.push(
`-e`, `SUBMISSIONID=${submissionId}`,
`-e`, `PHASE=${phase}`,
`-e`, `SKIP_STUDENT_TESTS=${skipStudentTests ? "true" : "false"}`,
`-w`, `${workDir}`,
`--name`, `container${submissionId}`,
`${optionsForDocker.dockerImage}`,
`timeout`, `${optionsForDocker.timeoutPerDockerRun}`,
`${optionsForDocker.dockerShellCmd}`, `${optionsForDocker.dockerScript}`);
log("docker " + dockerArgs.join(' '));
if (optionsForDocker.dry) {
log("Dry run, skip actual docker call.");
} else {
const startDockerTime = hrtime.bigint();
const returnCode = await execShell("docker", dockerArgs, { ...options, colored: options.colored || false });
const endDockerTime = hrtime.bigint();
const dockerDuration = timeDiff(startDockerTime, endDockerTime);
if (returnCode===124) {
warn(`Docker run timed out after ${optionsForDocker.timeoutPerDockerRun} seconds.`);
}
log(`Check needed ${dockerDuration} minutes.`);
await ensureFileExists(path.join(reportsDir, `_${submissionId}_junit.check.xml`));
}
}
export function parsePrepareSubmissionCmd(prepareSubmissionCmd?: string) {
if (prepareSubmissionCmd && prepareSubmissionCmd.length > 0) {
const all = prepareSubmissionCmd.split(' ');
const cmd = all.shift();
if (!cmd) {
program.error(`prepareSubmissionCmd must start with the command to execute.`);
}
return { cmd: cmd, args: all };
} else {
return undefined;
}
}
export async function createClipSubmissionDir(clipDir: any, submissionId: string) {
if (clipDir) {
const clipUserDir = path.join(clipDir, submissionId);
if (await folderExists(clipUserDir)) {
await rmDir(clipUserDir);
}
await createDir(clipUserDir);
return clipUserDir;
}
return undefined;
}
export async function rmClipSubmissionDir(clipUserDir: any, submissionId: string) {
if (clipUserDir) {
if (await isEmptyDir(clipUserDir)) {
await rmDir(clipUserDir);
}
}
}
export async function runPreCheckScript(preCheckScript: string, projectDir: string, clipUserDir: string | undefined, checkDir: string, prepare: boolean, options: OptionValues) {
if (preCheckScript) {
await runPrePostCheckScript("pre", preCheckScript, projectDir, clipUserDir, checkDir, prepare, options);
}
}
export async function runPostCheckScript(postCheckScript: string, projectDir: string, clipUserDir: string | undefined, checkDir: string, prepare: boolean, options: OptionValues) {
if (postCheckScript) {
await runPrePostCheckScript("post", postCheckScript, projectDir, clipUserDir, checkDir, prepare, options);
}
}
async function runPrePostCheckScript(phase: "pre" | "post", checkScript: string, projectDir: string, clipUserDir: string | undefined, checkDir: string, prepare: boolean, options: OptionValues) {
if (await fileExists(checkScript, `name of ${phase}CheckScript not provided`)) {
const env: NodeJS.ProcessEnv = {
...process.env,
CHECK: checkDir,
PROJECT: projectDir
}
if (clipUserDir) {
env.CLIP = clipUserDir;
}
if (prepare) {
env.PREPARE = "1";
} else {
env.PREPARE = "0";
}
await execShell("bash", [checkScript],
{ ...options, colored: options.colored || false, colorStandard: options.colorPost },
{
indent: true,
prefix: `${phase}> `,
cwd: undefined, // use current working directory
env: env
});
}
}