Part 2: Static Docker Registry in Rust
tldr; Part 2 of a multi-part series on writing a static Docker registry in Rust.
Choosing a Web Framework
For this use case, size is more of a concern over feature set.
Perusing crates.io, I narrowed down the search using the http-server
category. https://crates.io/categories/web-programming::http-server
The top server libraries (hyper
, actix-http
, rocket
) were all too large for my liking. Eventually I came across the tiny_http
library, which served up a low level HTTP implementation. I then saw a minimal web framework wrapper written for it called rouille
.
Below is a hello world example using rouille
, and rouille
's router!
macro.
use rouille::{router, Response};
use std::io;
fn main() {
// Listen on all interfaces on port 8000
rouille::start_server("0.0.0.0:8000", move |request| {
// Use rouille's basic logger to see requests
rouille::log(request, io::stdout(), || {
// Use rouille's router macro to match routes
router!(request,
(GET) (/) => {
// Return a response with a 200 OK status code and given text
Response::text("Why Hello There!")
},
// respond 404 for any other routes
_ => Response::empty_404()
})
});
}
Checking this works:
$ cargo run
...
$ curl localhost:8000
Why Hello There!
The Docker Registry API Spec
The Docker Registry API is documented here: https://docs.docker.com/registry/spec/api/. I'll be honest, its not great. While the API is simple enough, the actual documentation around replicating it in another language is lacking. To better familiarize myself, I stood up a local registry and did some exploration w/ cURL and insomnia (a Postman alternative).
$ docker run -d -p 666:5000 --name registry registry:2.8.1
# port 5000 is used on MacOS: `lsof -i :5000`
# lets see what it has to say for itself
$ curl localhost:666/ -vvv
* Trying 127.0.0.1:666...
* Connected to localhost (127.0.0.1) port 666 (#0)
> GET / HTTP/1.1
> Host: localhost:666
> User-Agent: curl/7.84.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Cache-Control: no-cache
< Date: Wed, 26 Oct 2022 18:06:34 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
# the API spec says that /v2 should return a 301 to /v2/
$ curl localhost:666/v2 -vvv
* Trying 127.0.0.1:666...
* Connected to localhost (127.0.0.1) port 666 (#0)
> GET /v2 HTTP/1.1
> Host: localhost:666
> User-Agent: curl/7.84.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 301 Moved Permanently
< Content-Type: text/html; charset=utf-8
< Docker-Distribution-Api-Version: registry/2.0
< Location: /v2/
< Date: Wed, 26 Oct 2022 18:08:01 GMT
< Content-Length: 39
<
<a href="/v2/">Moved Permanently</a>.
* Connection #0 to host localhost left intact
Ok, so we have a basic server up and running. Now we need to implement the API.
Implementing the API
Translating the above API routes into Rust, we get:
// within the router! macro:
(GET) (/) => {
// mirror from docker api
let body = ResponseBody::empty();
Response { status_code: 200, data: body, headers: vec![], upgrade: None }.with_additional_header("Cache-Control", "no-cache")
},
(GET) (/v2) => {
// redirect to /v2/
Response {
status_code: 301,
data: ResponseBody::from_string("<a href=\"/v2/\">Moved Permanently</a>.\n"),
headers: vec![("Location".into(), "/v2/".into())],
upgrade: None,
}.with_unique_header("Content-Type", "text/html; charset=utf-8")
},
(GET) (/v2/) => {
// returns empty json w/ Docker-Distribution-Api-Version header set
Response::text("{}")
.with_unique_header("Content-Type", "application/json; charset=utf-8")
.with_additional_header("Docker-Distribution-Api-Version", "registry/2.0")
.with_additional_header("X-Content-Type-Options", "nosniff")
},
The most important endpoint from the above is the /v2/
enpoint, which is used by clients to determine the API version via the Docker-Distribution-Api-Version
header. This is the first step in the Docker Registry API handshake.
The next post will cover pulling a v2 manifest.
Bonus: Writing E2E Tests
I wanted to write some E2E tests to ensure that the API was working as expected. I decided to use ureq
for this, as it is a simple HTTP client library.
NOTE: when this is merged into the main
zarf
repo, the tests will be included in Go's existing E2E test suite.
#[cfg(test)]
mod tests {
#[test]
fn api_is_alive() {
let resp = ureq::get("http://localhost:8000/")
.call()
.expect("api is not alive");
assert_eq!(resp.status(), 200);
}
#[test]
fn v2_redirect() {
// by default, ureq follows redirects x5
let resp = ureq::builder()
.redirects(0)
.build()
.get("http://localhost:8000/v2")
.call()
.expect("api is not alive");
assert_eq!(resp.status(), 301);
assert_eq!(resp.header("Location"), Some("/v2/"));
}
#[test]
fn v2_real() {
let resp = ureq::get("http://localhost:8000/v2/")
.call()
.expect("api is not alive");
assert_eq!(resp.status(), 200);
assert_eq!(
resp.header("Content-Type"),
Some("application/json; charset=utf-8")
);
assert_eq!(
resp.header("Docker-Distribution-Api-Version"),
Some("registry/2.0")
);
assert!(resp.into_string().expect("unable to parse").contains("{}"));
}
}