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("{}"));
    }
}