Compare commits

..
2 Commits
Author SHA1 Message Date
Lilac 8b550cfe10 inline hooks.json 2026-10-16 00:10:59 -04:00
Lilac 34841fa222 rate limiting and database saving works 2026-10-15 23:45:11 -04:00
3 changed files with 97 additions and 49 deletions
+42 -4
View File
@@ -80,17 +80,55 @@
(builtins.readFile ./pigeon.sh); (builtins.readFile ./pigeon.sh);
}; };
hooks = pkgs.writeText "pigeon-hooks.json" hooks = pkgs.writeText "pigeon-hooks.json" (builtins.toJSON [
(builtins.replaceStrings [ "@pigeon@" ] [ "${pigeon}/bin/pigeon" ] {
(builtins.readFile ./hooks.json)); id = "release";
execute-command = "${pkgs.nq}/bin/nq";
include-command-output-in-response = false;
pass-arguments-to-command = [
{ source = "string"; name = "${pigeon}/bin/pigeon"; }
{ source = "payload"; name = "project.id"; }
{ source = "payload"; name = "project.namespace"; }
{ source = "payload"; name = "project.name"; }
{ source = "payload"; name = "commit.id"; }
{ source = "payload"; name = "tag"; }
{ source = "payload"; name = "created_at"; }
];
trigger-rule.and = [
{
match = {
type = "value";
parameter = { source = "header"; name = "X-Gitlab-Event"; };
value = "Release Hook";
};
}
{
match = {
type = "value";
parameter = { source = "payload"; name = "action"; };
value = "create";
};
}
{
match = {
type = "value";
parameter = { source = "header"; name = "X-Gitlab-Token"; };
value = "{{ getenv `PIGEON_GITLAB_SECRET` | js }}";
};
}
];
}
]);
pigeon-webhook = pkgs.writeShellApplication { pigeon-webhook = pkgs.writeShellApplication {
name = "pigeon-webhook"; name = "pigeon-webhook";
runtimeInputs = with pkgs; [ webhook ]; runtimeInputs = with pkgs; [ coreutils nq sqlite webhook ];
text = '' text = ''
: "''${PIGEON_GITLAB_SECRET:?}" : "''${PIGEON_GITLAB_SECRET:?}"
: "''${PIGEON_GITLAB_TOKEN:?}" : "''${PIGEON_GITLAB_TOKEN:?}"
: "''${PIGEON_PROJECTS:?}" : "''${PIGEON_PROJECTS:?}"
export NQDIR=./queue
exec webhook -hooks ${hooks} -template -port 3000 -verbose -debug exec webhook -hooks ${hooks} -template -port 3000 -verbose -debug
''; '';
}; };
-39
View File
@@ -1,39 +0,0 @@
[
{
"id": "release",
"execute-command": "@pigeon@",
"include-command-output-in-response": false,
"pass-arguments-to-command": [
{ "source": "payload", "name": "project.id" },
{ "source": "payload", "name": "project.namespace" },
{ "source": "payload", "name": "project.name" },
{ "source": "payload", "name": "commit.id" },
{ "source": "payload", "name": "tag" }
],
"trigger-rule": {
"and": [
{
"match": {
"type": "value",
"parameter": { "source": "header", "name": "X-Gitlab-Event" },
"value": "Release Hook"
}
},
{
"match": {
"type": "value",
"parameter": { "source": "payload", "name": "action" },
"value": "create"
}
},
{
"match": {
"type": "value",
"parameter": { "source": "header", "name": "X-Gitlab-Token" },
"value": "{{ getenv `PIGEON_GITLAB_SECRET` | js }}"
}
}
]
}
}
]
+55 -6
View File
@@ -3,9 +3,56 @@ NAMESPACE="$2"
PROJECT="$3" PROJECT="$3"
COMMIT="$4" COMMIT="$4"
TAG="$5" TAG="$5"
CREATED_AT="$6"
: "${PIGEON_GITLAB_TOKEN:?}" : "${PIGEON_GITLAB_TOKEN:?}"
: "${PIGEON_PROJECTS:?}" : "${PIGEON_PROJECTS:?}"
: "${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
DIRECTORY="$(mktemp -d /tmp/pigeon.XXXXXXXXXX)" DIRECTORY="$(mktemp -d /tmp/pigeon.XXXXXXXXXX)"
trap 'rm -rf "$DIRECTORY"' EXIT trap 'rm -rf "$DIRECTORY"' EXIT
@@ -42,18 +89,20 @@ EOF
firecracker --no-api --level Error --config-file vm.json firecracker --no-api --level Error --config-file vm.json
RESULT="" RESULT=""
SCORE=0
TESTS="$PIGEON_PROJECTS/$PROJECT/tests" TESTS="$PIGEON_PROJECTS/$PROJECT/tests"
for TEST in "$TESTS"/*.sh ; do for TEST in "$TESTS"/*.sh ; do
debugfs -R "cat /$(basename "$TEST" .sh)" results.img >scratch 2>/dev/null debugfs -R "cat /$(basename "$TEST" .sh)" results.img >scratch 2>/dev/null
if cmp scratch "$TESTS/$(basename "$TEST" .sh).expected" ; then if cmp -s scratch "$TESTS/$(basename "$TEST" .sh).expected" ; then
RESULT="${RESULT}PASS\n" RESULT="${RESULT}PASS\n"
SCORE=$((SCORE + 1))
else else
RESULT="${RESULT}FAIL: $(cat "$TESTS/$(basename "$TEST" .sh).hint")\n" RESULT="${RESULT}FAIL: $(cat "$TESTS/$(basename "$TEST" .sh).hint")\n"
fi fi
done done
curl -s --show-error --out-null --request PUT \ # Record before reporting, so a GitLab outage doesn't lose the result.
--header "PRIVATE-TOKEN: $PIGEON_GITLAB_TOKEN" \ db "UPDATE results SET score = $SCORE WHERE id = $ROW;" ||
--header 'Content-Type: application/json' \ echo "pigeon: failed to record score for row $ROW in $PIGEON_DB" >&2
--data "{\"description\": \"$RESULT\"}" \
--url "https://gitlab.com/api/v4/projects/$ID/releases/$TAG" report "$RESULT"