Running UNSW CSE autotests from my own machine with Rust
Thursday, February 15, 2024 / , ,
Summary:
UNSW CSE's autotest and give commands only run on the university's servers, so I wrote a small Rust tool to upload my local directory, run a command remotely, and bring the output back.
Most UNSW CSE assignments involve two commands: autotest to run the tests, and give to submit the work. Both are only available on the university's servers, so wherever I write the code, I still have to log in to a CSE server to run them.
I'm learning Rust this term in COMP6991, and I'd been looking for a small project to practise with. This seemed like a good fit, so I made CSERun.
The hassle of developing locally
Writing code directly on the server works fine, whether with vim or VS Code's remote features. But I prefer working on my own machine, where my editor and configuration are already set up. I only need the server when it's time to run the tests.
That means every change comes with the same tedious routine:
What I wanted
I wanted something simple: one command, run from the assignment directory on my own machine.
It should connect to the server, authenticate, upload the current directory, run the command there, and display the output as-is in my local terminal.
A fresh directory for every run
To keep old files from interfering, each run gets a new directory on the server. Its name is the current timestamp, and it lives under ~/.cserun/temp/. Uploaded files go into a container subdirectory. I also save the command in command.txt, in case I want to check what I ran later.
The command runs inside container, so from its point of view, it's running in a clean assignment directory.
Syncing files
For the connection and uploads, I use ssh2, a wrapper around libssh2. It handles authentication, SFTP, and command execution.
To decide which files to upload, I use the ignore crate to walk the local directory. It's the same crate used by ripgrep. It reads .gitignore and .ignore automatically, so directories such as target and node_modules won't be uploaded if they're already listed in .gitignore. I don't have to maintain another set of exclusion rules.
Some commands don't need any local files, such as 6991 classrun -sturec. For those, I added --no-sync, which skips the upload and just creates an empty directory.
Authentication
Connection settings live in ~/.config/cserun/config.toml. On the first run, if the file doesn't exist, the program creates a template and asks me to fill it in.
There are three authentication methods: a password, a key file, or ssh-agent. A Rust enum fits this nicely, with the data each method needs stored in its corresponding variant.
A match handles each method. If I add a variant or miss a case, the compiler reminds me.
If password authentication is selected but no password is saved in the configuration file, rpassword prompts for it at runtime without echoing it on the screen.
Output
I wanted output to appear as the command runs, just as it would locally, rather than arrive all at once after the command finishes. So I put the connection in non-blocking mode, repeatedly read standard output and standard error, and print whatever arrives straight away.
In non-blocking mode, a read can return WouldBlock when no data is available yet. That isn't a failure; it just needs its own match arm so the loop can continue.
I also added --env to set environment variables for the command. This came with a small snag: libssh2's setenv doesn't always work on the CSE server. If it fails, I fall back to prepending an export to the command.
Command-line arguments
I used clap's derive API for the command-line arguments. A few attributes on a struct provide both argument parsing and the help text.
The relationships between arguments can be declared in those attributes too. For example, --no-sync and --env require a command, while --config must be used on its own. That saves me from writing those checks myself.
Using it
With Rust installed, installation takes one command:
Then I can run it from an assignment directory. Here's an example that skips file syncing and sets a couple of environment variables:
For everyday use, it's just cserun "6991 autotest". I edit the code locally, run the command, and the test results appear in my own terminal.