diff --git a/BackendAcademy/.dockerignore b/BackendAcademy/.dockerignore new file mode 100644 index 0000000000..29ae320328 --- /dev/null +++ b/BackendAcademy/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +.env +.git \ No newline at end of file diff --git a/BackendAcademy/README.md b/BackendAcademy/README.md index 527c10f517..1c9a4d0d3c 100644 --- a/BackendAcademy/README.md +++ b/BackendAcademy/README.md @@ -40,14 +40,14 @@ Implements the README's reward flow — AI grader scores a submission off-chain, a tutor confirms or overrides that score, and the final score triggers rewards. Routes are under `api/v1/grading`: -| Method | Path | Purpose | -| ------ | ---- | ------- | -| `POST` | `/submissions` | Accept a learner submission and open its audit trail | -| `POST` | `/submissions/:id/ai-pre-score` | Stage 1 — run the Claude grader (score + feedback) | -| `POST` | `/submissions/:id/review` | Stage 2 — tutor `confirm`s or `override`s the pre-score | -| `GET` | `/submissions/:id` | Submission with its AI pre-score and tutor review | -| `GET` | `/submissions/:id/history` | Append-only status-transition audit trail | -| `GET` | `/queue` | AI pre-scored submissions awaiting tutor review | +| Method | Path | Purpose | +| ------ | ------------------------------- | ------------------------------------------------------- | +| `POST` | `/submissions` | Accept a learner submission and open its audit trail | +| `POST` | `/submissions/:id/ai-pre-score` | Stage 1 — run the Claude grader (score + feedback) | +| `POST` | `/submissions/:id/review` | Stage 2 — tutor `confirm`s or `override`s the pre-score | +| `GET` | `/submissions/:id` | Submission with its AI pre-score and tutor review | +| `GET` | `/submissions/:id/history` | Append-only status-transition audit trail | +| `GET` | `/queue` | AI pre-scored submissions awaiting tutor review | Statuses move `submitted → ai_graded → tutor_confirmed | tutor_overridden`, and every move is appended to the submission's transition log. A `tutor_review` @@ -64,12 +64,12 @@ meet the course's minimum (default: the grading pass threshold). The certificate minting job consults these routes before minting; routes are under `api/v1/certificates`: -| Method | Path | Purpose | -| ------ | ---- | ------- | -| `POST` | `/courses` | Register the task ids + minimum score a certificate requires | -| `POST` | `/results` | Record a learner's final task score from the grading pipeline | -| `GET` | `/users/:userId/courses/:courseId/eligibility` | Eligibility verdict for one learner | -| `GET` | `/courses/:courseId/eligible-learners` | Every learner the job may mint for | +| Method | Path | Purpose | +| ------ | ---------------------------------------------- | ------------------------------------------------------------- | +| `POST` | `/courses` | Register the task ids + minimum score a certificate requires | +| `POST` | `/results` | Record a learner's final task score from the grading pipeline | +| `GET` | `/users/:userId/courses/:courseId/eligibility` | Eligibility verdict for one learner | +| `GET` | `/courses/:courseId/eligible-learners` | Every learner the job may mint for | `GET .../eligibility` returns `eligible`, the effective per-task results, and a `finalScore` (the rounded mean of the task scores) for the mint to record. Only @@ -88,10 +88,12 @@ bar but never lower the certificate. ## Sandbox and Stellar configuration -The Rust runner requires a reachable Docker Engine and the configured Rust image -(`RUSTACADEMY_SANDBOX_IMAGE`, default `rust:1.86-slim`). Each run has network -access disabled and is bounded by container CPU, memory, PID, wall-time, and -output limits. Keep Docker Engine access restricted to this service. +The Rust runner requires a reachable Docker Engine and the configured sandbox +image (`RUSTACADEMY_SANDBOX_IMAGE`, default +`rustacademy-wasm-sandbox:1.86-wasmtime-29.0.1`). Build instructions are in the +Rust WASM task execution section below. Each run has network access disabled and +is bounded by container CPU, memory, PID, wall-time, and output limits. Keep +Docker Engine access restricted to this service. Set `STELLAR_NETWORK` to `testnet` (default) or `mainnet`. Configure `REWARD_POOL_SECRET` only in a secret store; it is never returned by the API. @@ -103,3 +105,23 @@ For cross-instance submission controls, configure `REDIS_REST_URL` and are process-local and reset when the service restarts. Tune `SUBMISSION_RATE_LIMIT` (default 5), `SUBMISSION_RATE_WINDOW_SECONDS` (default 60), and `SUBMISSION_DUPLICATE_WINDOW_SECONDS` (default 30) as needed. + +## Rust WASM task execution + +Build the sandbox runner image with Docker: + +```bash +docker build -f BackendAcademy/sandbox.Dockerfile \ + -t rustacademy-wasm-sandbox:1.86-wasmtime-29.0.1 BackendAcademy +``` + +`POST /api/tasks/run` accepts the existing `source` field. To execute test cases, +also provide a `testCases` array of stdin strings and the shared `expectedOutput`. +The response includes compilation status and per-case pass/fail, stdout, and +elapsed duration (including compilation). Set `RUSTACADEMY_SANDBOX_IMAGE` to use +a differently tagged image. + +The runner compiles to `wasm32-wasip1` and executes with Wasmtime. Each run has +no network access or WASI directory preopens, a 10-second compile timeout, a +2-second execution timeout, a 16 MiB guest-memory limit, 10 million fuel, and +the existing Docker CPU, memory, PID, and output limits. diff --git a/BackendAcademy/sandbox.Dockerfile b/BackendAcademy/sandbox.Dockerfile new file mode 100644 index 0000000000..d088a0a140 --- /dev/null +++ b/BackendAcademy/sandbox.Dockerfile @@ -0,0 +1,8 @@ +FROM rust:1.86-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential cmake pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN rustup target add wasm32-wasip1 \ + && cargo install wasmtime-cli --version 29.0.1 --locked --root /usr/local \ No newline at end of file diff --git a/BackendAcademy/src/sandbox/dto/run-code.dto.ts b/BackendAcademy/src/sandbox/dto/run-code.dto.ts index 483a5f3b39..0f46659e63 100644 --- a/BackendAcademy/src/sandbox/dto/run-code.dto.ts +++ b/BackendAcademy/src/sandbox/dto/run-code.dto.ts @@ -1,8 +1,30 @@ -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateIf, +} from "class-validator"; export class RunCodeDto { @IsString() @IsNotEmpty() @MaxLength(100_000) source: string; -} \ No newline at end of file + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(10) + @IsString({ each: true }) + @MaxLength(16_384, { each: true }) + testCases?: string[]; + + @ValidateIf((dto: RunCodeDto) => dto.testCases !== undefined) + @IsString() + @MaxLength(64 * 1024) + expectedOutput?: string; +} diff --git a/BackendAcademy/src/sandbox/sandbox.controller.ts b/BackendAcademy/src/sandbox/sandbox.controller.ts index 1328cad80b..f7789f845f 100644 --- a/BackendAcademy/src/sandbox/sandbox.controller.ts +++ b/BackendAcademy/src/sandbox/sandbox.controller.ts @@ -1,15 +1,22 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { RunCodeDto } from './dto/run-code.dto'; -import { SandboxService } from './sandbox.service'; +import { Body, Controller, Post } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { RunCodeDto } from "./dto/run-code.dto"; +import { SandboxService } from "./sandbox.service"; -@ApiTags('sandbox') -@Controller('tasks') +@ApiTags("sandbox") +@Controller("tasks") export class SandboxController { constructor(private readonly sandbox: SandboxService) {} - @Post('run') + @Post("run") run(@Body() dto: RunCodeDto) { + if (dto.testCases !== undefined) { + return this.sandbox.runRustTests( + dto.source, + dto.testCases, + dto.expectedOutput, + ); + } return this.sandbox.runRust(dto.source); } -} \ No newline at end of file +} diff --git a/BackendAcademy/src/sandbox/sandbox.service.spec.ts b/BackendAcademy/src/sandbox/sandbox.service.spec.ts new file mode 100644 index 0000000000..75d7ccf501 --- /dev/null +++ b/BackendAcademy/src/sandbox/sandbox.service.spec.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from "@nestjs/common"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { SandboxService } from "./sandbox.service"; + +describe("SandboxService", () => { + let service: SandboxService; + + beforeEach(() => { + service = new SandboxService(); + }); + + it("returns pass/fail and output for each test case", async () => { + const runContainer = jest + .spyOn(service as any, "runContainer") + .mockResolvedValueOnce({ + stdout: "42\r\n", + stderr: "", + exitCode: 0, + timedOut: false, + durationMs: 34, + compiled: true, + }) + .mockResolvedValueOnce({ + stdout: "wrong", + stderr: "", + exitCode: 0, + timedOut: false, + durationMs: 29, + compiled: true, + }); + + const result = await service.runRustTests( + "fn main() {}", + ["1\n", "2\n"], + "42", + ); + + expect(result).toEqual({ + compiled: true, + passed: false, + results: [ + { index: 0, passed: true, stdout: "42\r\n", durationMs: 34 }, + { index: 1, passed: false, stdout: "wrong", durationMs: 29 }, + ], + }); + expect(runContainer).toHaveBeenCalledTimes(2); + expect(runContainer.mock.calls[0]).toEqual(["fn main() {}", "1\n"]); + expect(runContainer.mock.calls[1]).toEqual(["fn main() {}", "2\n"]); + }); + + it("reports compilation failure without running test cases", async () => { + jest.spyOn(service as any, "runContainer").mockResolvedValueOnce({ + stdout: "", + stderr: "error: expected item", + exitCode: 1, + timedOut: false, + durationMs: 16, + compiled: false, + }); + + await expect( + service.runRustTests("not rust", ["input"], "output"), + ).resolves.toEqual({ + compiled: false, + passed: false, + results: [], + compileError: "error: expected item", + }); + }); + + it("rejects empty test-case lists", async () => { + await expect( + service.runRustTests("fn main() {}", [], ""), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/BackendAcademy/src/sandbox/sandbox.service.ts b/BackendAcademy/src/sandbox/sandbox.service.ts index 3c5ebc71f5..5dfb5f6e81 100644 --- a/BackendAcademy/src/sandbox/sandbox.service.ts +++ b/BackendAcademy/src/sandbox/sandbox.service.ts @@ -1,99 +1,253 @@ import { + BadRequestException, Injectable, PayloadTooLargeException, ServiceUnavailableException, -} from '@nestjs/common'; -import { spawn } from 'child_process'; +} from "@nestjs/common"; +import { spawn } from "child_process"; export interface SandboxResult { stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; + durationMs: number; +} + +export interface SandboxTestCaseResult { + index: number; + passed: boolean; + stdout: string; + durationMs: number; + error?: string; +} + +export interface SandboxTestsResult { + compiled: boolean; + passed: boolean; + results: SandboxTestCaseResult[]; + compileError?: string; +} + +interface ContainerResult extends SandboxResult { + compiled: boolean; } const MAX_OUTPUT_BYTES = 64 * 1024; -const MAX_PARALLEL_RUNS = 50; +const MAX_TEST_CASES = 10; +const MAX_PARALLEL_RUNS = 4; +const COMPILE_TIMEOUT_SECONDS = 10; +const EXECUTION_TIMEOUT_SECONDS = 2; +const COMPILE_SUCCESS_MARKER = "__RUSTACADEMY_WASM_COMPILE_SUCCESS__"; @Injectable() export class SandboxService { private activeRuns = 0; async runRust(source: string): Promise { + return this.withRunSlot(async () => { + const result = await this.runContainer(source, ""); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + timedOut: result.timedOut, + durationMs: result.durationMs, + }; + }); + } + + async runRustTests( + source: string, + testCases: string[], + expectedOutput: string, + ): Promise { + if (testCases.length === 0 || testCases.length > MAX_TEST_CASES) { + throw new BadRequestException( + `Provide between 1 and ${MAX_TEST_CASES} test cases`, + ); + } + + return this.withRunSlot(async () => { + const results: SandboxTestCaseResult[] = []; + + for (const [index, input] of testCases.entries()) { + const execution = await this.runContainer(source, input); + if (!execution.compiled) { + return { + compiled: false, + passed: false, + results: [], + compileError: execution.timedOut + ? "Compilation timed out" + : execution.stderr || "Rust compilation failed", + }; + } + + const error = execution.timedOut + ? "Execution timed out" + : execution.exitCode !== 0 + ? execution.stderr || + `Program exited with code ${execution.exitCode}` + : undefined; + + results.push({ + index, + passed: + !error && + this.normalizeOutput(execution.stdout) === + this.normalizeOutput(expectedOutput), + stdout: execution.stdout, + durationMs: execution.durationMs, + ...(error ? { error } : {}), + }); + } + + return { + compiled: true, + passed: results.every((result) => result.passed), + results, + }; + }); + } + + private async withRunSlot(run: () => Promise): Promise { if (this.activeRuns >= MAX_PARALLEL_RUNS) { - throw new ServiceUnavailableException('Sandbox capacity is full; retry shortly'); + throw new ServiceUnavailableException( + "Sandbox capacity is full; retry shortly", + ); } this.activeRuns += 1; try { - return await this.runContainer(source); + return await run(); } finally { this.activeRuns -= 1; } } - private runContainer(source: string): Promise { - const image = process.env.RUSTACADEMY_SANDBOX_IMAGE ?? 'rust:1.86-slim'; + private runContainer( + source: string, + input: string, + ): Promise { + const image = + process.env.RUSTACADEMY_SANDBOX_IMAGE ?? + "rustacademy-wasm-sandbox:1.86-wasmtime-29.0.1"; + const sourceBuffer = Buffer.from(source, "utf8"); + const inputBuffer = Buffer.from(input, "utf8"); + const inputPayload = Buffer.concat([ + Buffer.from(`${sourceBuffer.length}\n`), + sourceBuffer, + Buffer.from(`${inputBuffer.length}\n`), + inputBuffer, + ]); + const runnerCommand = [ + "IFS= read -r source_len", + 'head -c "$source_len" > /tmp/main.rs', + "IFS= read -r separator", + "IFS= read -r input_len", + 'head -c "$input_len" > /tmp/stdin', + `timeout -k 1s ${COMPILE_TIMEOUT_SECONDS}s rustc --edition=2021 --target=wasm32-wasip1 --crate-type=bin -o /tmp/main.wasm /tmp/main.rs`, + `printf '%s\\n' '${COMPILE_SUCCESS_MARKER}' >&2`, + `timeout -k 1s ${EXECUTION_TIMEOUT_SECONDS}s wasmtime run --fuel 10000000 --max-memory-size 16777216 /tmp/main.wasm < /tmp/stdin`, + ].join(" && "); const args = [ - 'run', '--rm', '-i', - '--network=none', - '--memory=128m', - '--memory-swap=128m', - '--cpus=0.5', - '--pids-limit=32', - '--read-only', - '--tmpfs=/tmp:rw,nosuid,size=64m', - '--cap-drop=ALL', - '--security-opt=no-new-privileges', - '--user=65534:65534', + "run", + "--rm", + "-i", + "--network=none", + "--memory=512m", + "--memory-swap=512m", + "--cpus=0.5", + "--pids-limit=32", + "--read-only", + "--tmpfs=/tmp:rw,nosuid,size=64m", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--user=65534:65534", image, - 'sh', '-c', - 'timeout -k 1s 10s sh -c \'cat > /tmp/main.rs && rustc /tmp/main.rs -o /tmp/main && /tmp/main\'', + "sh", + "-c", + runnerCommand, ]; return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; + const startedAt = Date.now(); + let stdout = ""; + let stderr = ""; let outputBytes = 0; let timedOut = false; let settled = false; - const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let child; + + try { + child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] }); + } catch { + reject( + new ServiceUnavailableException("Sandbox runtime is unavailable"), + ); + return; + } + const timer = setTimeout(() => { timedOut = true; - child.kill('SIGKILL'); - }, 12_000); + child.kill("SIGKILL"); + }, 15_000); - const append = (target: 'stdout' | 'stderr', chunk: Buffer) => { + const append = (target: "stdout" | "stderr", chunk: Buffer) => { outputBytes += chunk.length; if (outputBytes > MAX_OUTPUT_BYTES) { - child.kill('SIGKILL'); + child.kill("SIGKILL"); if (!settled) { settled = true; clearTimeout(timer); - reject(new PayloadTooLargeException('Sandbox output exceeded 64 KiB')); + reject( + new PayloadTooLargeException("Sandbox output exceeded 64 KiB"), + ); } return; } - if (target === 'stdout') stdout += chunk.toString('utf8'); - else stderr += chunk.toString('utf8'); + if (target === "stdout") stdout += chunk.toString("utf8"); + else stderr += chunk.toString("utf8"); }; - child.stdout.on('data', (chunk: Buffer) => append('stdout', chunk)); - child.stderr.on('data', (chunk: Buffer) => append('stderr', chunk)); - child.on('error', () => { + child.stdout.on("data", (chunk: Buffer) => append("stdout", chunk)); + child.stderr.on("data", (chunk: Buffer) => append("stderr", chunk)); + child.on("error", () => { clearTimeout(timer); if (!settled) { settled = true; - reject(new ServiceUnavailableException('Sandbox runtime is unavailable')); + reject( + new ServiceUnavailableException("Sandbox runtime is unavailable"), + ); } }); - child.on('close', (exitCode) => { + child.on("close", (exitCode) => { clearTimeout(timer); if (settled) return; settled = true; - resolve({ stdout, stderr, exitCode, timedOut: timedOut || exitCode === 124 || exitCode === 137 }); + const markerIndex = stderr.indexOf(COMPILE_SUCCESS_MARKER); + const compiled = markerIndex >= 0; + if (compiled) { + stderr = `${stderr.slice(0, markerIndex)}${stderr.slice( + markerIndex + COMPILE_SUCCESS_MARKER.length, + )}`.replace(/^\r?\n/, ""); + } + resolve({ + stdout, + stderr, + exitCode, + timedOut: timedOut || exitCode === 124 || exitCode === 137, + durationMs: Date.now() - startedAt, + compiled, + }); }); - child.stdin.end(source); + child.stdin.on("error", () => undefined); + child.stdin.end(inputPayload); }); } -} \ No newline at end of file + + private normalizeOutput(output: string): string { + return output.replace(/\r\n/g, "\n").replace(/\n+$/, ""); + } +}