2026-10-15 20:33:59 -04:00
|
|
|
ID="$1"
|
|
|
|
|
NAMESPACE="$2"
|
|
|
|
|
PROJECT="$3"
|
|
|
|
|
COMMIT="$4"
|
|
|
|
|
TAG="$5"
|
2026-10-15 23:45:11 -04:00
|
|
|
CREATED_AT="$6"
|
2026-10-15 20:33:59 -04:00
|
|
|
|
|
|
|
|
: "${PIGEON_GITLAB_TOKEN:?}"
|
|
|
|
|
: "${PIGEON_PROJECTS:?}"
|
2026-10-15 23:45:11 -04:00
|
|
|
: "${PIGEON_DB:?}"
|
|
|
|
|
LIMIT="${PIGEON_RATE_LIMIT:-3}" # graded attempts per student per project per hour
|
|
|
|
|
|
|
|
|
|
PIGEON_DB="$(realpath -m "$PIGEON_DB")"
|
|
|
|
|
db() { sqlite3 -bail -cmd '.timeout 10000' "$PIGEON_DB" "$@"; }
|
|
|
|
|
# quote a value as a SQL string literal
|
|
|
|
|
q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"; }
|
|
|
|
|
|
|
|
|
|
report() {
|
|
|
|
|
curl -s --show-error --out-null --request PUT \
|
|
|
|
|
--header "PRIVATE-TOKEN: $PIGEON_GITLAB_TOKEN" \
|
|
|
|
|
--header 'Content-Type: application/json' \
|
|
|
|
|
--data "{\"description\": \"$1\"}" \
|
|
|
|
|
--url "https://gitlab.com/api/v4/projects/$ID/releases/$TAG"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mkdir -p "$(dirname "$PIGEON_DB")"
|
|
|
|
|
db << 'EOF'
|
|
|
|
|
CREATE TABLE IF NOT EXISTS results (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
student TEXT NOT NULL,
|
|
|
|
|
project TEXT NOT NULL,
|
|
|
|
|
commit_sha TEXT NOT NULL,
|
|
|
|
|
time TEXT NOT NULL DEFAULT (datetime('now')),
|
|
|
|
|
score INTEGER CHECK (score >= 0)
|
|
|
|
|
);
|
|
|
|
|
EOF
|
|
|
|
|
|
|
|
|
|
# GitLab's "2026-10-16 02:27:46 UTC" -> SQLite's "2026-10-16 02:27:46".
|
|
|
|
|
# If it's missing or unparseable, fall back to the current time.
|
|
|
|
|
CREATED="$(date -u -d "$CREATED_AT" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || true)"
|
|
|
|
|
|
|
|
|
|
# Rate limit and record the submission in one atomic statement.
|
|
|
|
|
# No row comes back if the student is over the limit.
|
|
|
|
|
ROW="$(db "INSERT INTO results (student, project, commit_sha, time)
|
|
|
|
|
SELECT $(q "$NAMESPACE"), $(q "$PROJECT"), $(q "$COMMIT"),
|
|
|
|
|
COALESCE(NULLIF($(q "$CREATED"), ''), datetime('now'))
|
|
|
|
|
WHERE (SELECT COUNT(*) FROM results
|
|
|
|
|
WHERE student = $(q "$NAMESPACE") AND project = $(q "$PROJECT")
|
|
|
|
|
AND time > datetime('now', '-1 hour')) < $LIMIT
|
|
|
|
|
RETURNING id;")"
|
|
|
|
|
|
|
|
|
|
if [ -z "$ROW" ]; then
|
|
|
|
|
report "Rate limit reached: at most $LIMIT submissions per hour. Try again later."
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
2026-10-15 20:33:59 -04:00
|
|
|
|
|
|
|
|
DIRECTORY="$(mktemp -d /tmp/pigeon.XXXXXXXXXX)"
|
|
|
|
|
trap 'rm -rf "$DIRECTORY"' EXIT
|
|
|
|
|
cd "$DIRECTORY"
|
|
|
|
|
|
|
|
|
|
git -c advice.detachedHead=false clone -q --rev "$COMMIT" --depth 1 \
|
|
|
|
|
"https://oauth2:$PIGEON_GITLAB_TOKEN@gitlab.com/$NAMESPACE/$PROJECT.git" student/
|
|
|
|
|
rm -rf student/.git
|
|
|
|
|
|
|
|
|
|
mkdir -p project/tests project/build
|
|
|
|
|
cp -a "$PIGEON_PROJECTS/$PROJECT"/tests/*.sh project/tests/
|
|
|
|
|
cp -a "$PIGEON_PROJECTS/$PROJECT"/build/. project/build/
|
|
|
|
|
|
|
|
|
|
mkfs.ext4 -q -F -d student student.img 16M
|
|
|
|
|
mkfs.ext4 -q -F -d project project.img 16M
|
|
|
|
|
mkfs.ext4 -q -F results.img 16M
|
|
|
|
|
|
|
|
|
|
cat >vm.json <<'EOF'
|
|
|
|
|
{
|
|
|
|
|
"boot-source": {
|
|
|
|
|
"kernel_image_path": "@kernel@",
|
|
|
|
|
"boot_args": "reboot=k panic=1 init=/init quiet nomodule i8042.noaux i8042.nomux i8042.dumbkbd swiotlb=noforce console=ttyS0"
|
|
|
|
|
},
|
|
|
|
|
"drives": [
|
|
|
|
|
{ "drive_id": "rootfs", "path_on_host": "@rootfs@", "is_root_device": true, "is_read_only": true },
|
|
|
|
|
{ "drive_id": "student", "path_on_host": "student.img", "is_root_device": false, "is_read_only": true },
|
|
|
|
|
{ "drive_id": "project", "path_on_host": "project.img", "is_root_device": false, "is_read_only": true },
|
|
|
|
|
{ "drive_id": "results", "path_on_host": "results.img", "is_root_device": false, "is_read_only": false }
|
|
|
|
|
],
|
|
|
|
|
"machine-config": { "vcpu_count": 1, "mem_size_mib": 512 }
|
|
|
|
|
}
|
|
|
|
|
EOF
|
|
|
|
|
|
|
|
|
|
firecracker --no-api --level Error --config-file vm.json
|
|
|
|
|
|
|
|
|
|
RESULT=""
|
2026-10-15 23:45:11 -04:00
|
|
|
SCORE=0
|
2026-10-15 20:33:59 -04:00
|
|
|
TESTS="$PIGEON_PROJECTS/$PROJECT/tests"
|
|
|
|
|
for TEST in "$TESTS"/*.sh ; do
|
|
|
|
|
debugfs -R "cat /$(basename "$TEST" .sh)" results.img >scratch 2>/dev/null
|
2026-10-15 23:45:11 -04:00
|
|
|
if cmp -s scratch "$TESTS/$(basename "$TEST" .sh).expected" ; then
|
2026-10-15 20:33:59 -04:00
|
|
|
RESULT="${RESULT}PASS\n"
|
2026-10-15 23:45:11 -04:00
|
|
|
SCORE=$((SCORE + 1))
|
2026-10-15 20:33:59 -04:00
|
|
|
else
|
|
|
|
|
RESULT="${RESULT}FAIL: $(cat "$TESTS/$(basename "$TEST" .sh).hint")\n"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
|
2026-10-15 23:45:11 -04:00
|
|
|
# Record before reporting, so a GitLab outage doesn't lose the result.
|
|
|
|
|
db "UPDATE results SET score = $SCORE WHERE id = $ROW;" ||
|
|
|
|
|
echo "pigeon: failed to record score for row $ROW in $PIGEON_DB" >&2
|
|
|
|
|
|
|
|
|
|
report "$RESULT"
|