Writing X-docker-isolation-provider is difficult – but not impossible

Have you ever felt like a pioneer? This is exactly how I felt when I wrote docker-isolation-provider for the Deep associative programming platform.

It was like this: one fine day on our communications platform decided – it would be nice to port our bot V Deep. And for this you had to write like this called providers.

Providers are needed for only one purpose – to enable the user to execute custom handlers in any language. Then I just thought it would be nice to help guys who have probably never Rust in their lives did not see. Here is the list on npm: https://www.npmjs.com/search?q=docker-isolation-provider

Oh, how wrong I was then…

Stage 1: Denial

As a reference, I used a ready-made JavaScript provider. I made the provider in agreement with the guys from Deep, but without any specific feedback. They asked for a shorter code, so I didn’t add anything extra, I just rewrote it in the style of the JS provider. I even had to get some watermelon [rust-script](https://rust-script.org/)

Here's the link to the pull request: https://github.com/deep-foundation/rust-docker-isolation-provider/pull/1

To understand why exactly thisTd, you need to understand what exactly he must be able to do in order to be called sacred X-docker-isolation-provider. To begin with, I went to look at the ready-made JS provider

This is the sign that greets us on the GitHub page. The code is basically the same.

img

img

Of course it attracts attention and Docker in the title, by docker file It is clear that the image simply runs on the user’s machine, ensuring the execution of the code that is sent to the desired endpoint.

Well, there’s nothing more to catch here for now; the rest could be found out directly from the developers on their discord server. But to write in developer channels, you need to at least have role of cadet – ok, done.

image

image

Stage 2: Anger

When I made these small edits, I somehow didn’t really care what was in the title docker. But now the question kept spinning in my head: “Why at all? docker?”.
Why not, for example, use some WASM runtime with one standardized entry point, then there would be no need to build each provider from scratch. To which I soon received the answer – “this is for the client handler.” Okay, fewer questions, I thought, I just need to finish the provider. Just to move on I needed to find out how well my PR met the requirements.

It immediately became clear that no one from Deep was particularly interested in the code itself, but only in its functionality, so I focused on it.

In general, all the logic was in these lines, and the rest was just web server code

fs::write(
    /* handler.rs */,
    format!(
        "fn main() -> Result<(), Box<dyn std::error::Error>> {{ \\
            let args = serde_json::from_str(r#\\"{raw}\\"#)?; \\
            {code}
            println!(\\"{{}}\\", serde_json::to_string(&main(args))?);
            Ok(())
        }}"
    ),
)?;

let out =
    Command::new("rust-script").arg("-d serde_json=1.0").arg(/* handler.rs */).output()?;

Where the main idea immediately becomes clear. The handler code is simply inserted instead{code}. It should correspond to this format:

fn main(args: TYPE) -> RET {
    ...
}

Instead of TYPE anyone can stand impl Deserialize (transferred there params from the arrived json), and to the place RET any impl Serialize. This made it easy to accept almost any type without effort, thanks to serde.

Here is one of Postman's later screenshots, but it gets the point across

img_1

img_1

There was also no code validation here (and, accordingly, meaningless parsing – all responsibility was on the compiler).

For the same reason, only one argument is accepted (this, of course, can be changed to procedural macrosbut why if serde functionality solves this problem).

In general, this approach was not very well appreciated, and I had to explain for a very long time why this is better than blindly using dynamic json, as in the js provider.
But still a lot of questions remained:

  • If the provider is not for users, then why does it need isolation and dockerization? At a minimum, it would be nice to allow the use of tuning from the user’s machine (if he already has Rust installed)

  • why such a strange format for the received and returned data (this will come back to haunt me later)

  • and the strangest thing is that I learned that I can't just return a result (actually I can), because, as it turns out, I can only return a relationship that refers to the ones created through DeepClient data

Stage 3: Bargaining

By this time, I had already begun to rework the provider logic, gradually preparing it for expansion, making two main changes. To begin with, I moved the handler code template into a separate file, because it had grown quite large in size.

Why? Because a system was needed that would allow returning Result<T> (or not return).

You can look at this in more detail here

But if you look at it briefly, it all comes down to this code:

// Deep просит результат в таком json формате
// { "resolved": ок }
// { "rejected": не ок }
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum Respond<T, E> {
    Resolved(T),
    Rejected(E),
}

 #[derive(Serialize)]
pub struct Infallible {} // просто метафора на то, что T сериализуется как Result<T, Infallible>
// можно было просто мануально реализовать пустую реализацию, но компилятор и без этого должен нормально соптимизировать

pub trait Responder {
    fn respond_to<S: Serializer>(self, serializer: S) -> Option<S::Error>;
    // where (на верочку, так как всё равно сериалайзер юзер передать не может)
    //     S: Serializer<Ok = ()>;
}

impl<T: Serialize, E: Serialize> Responder for Result<T, E> {
    fn respond_to<S: Serializer>(self, serializer: S) -> Option<S::Error> {
        match self {
            Ok(ok) => Respond::Resolved(ok),
            Err(err) => Respond::Rejected(err),
        }
        .serialize(serializer)
        .err()
    }
}

impl<T: Serialize> Responder for T {
    default fn respond_to<S: Serializer>(self, serializer: S) -> Option<S::Error> {
        // Как говорилось выше, просто сериализуем `T` как `Result<T, Infallible>`
        Respond::<_, Infallible>::Resolved(self).serialize(serializer).err()
    }
}

I also added streaming everything stderr (including compilation errors and regular eprints) on /stream endpoint and, accordingly, a small macro that would allow you to convert the code into the required format for testing the provider.

macro_rules! rusty {
    (($($pats:tt)*) $(-> $ty:ty)? { $body:expr } $(where $args:expr)? ) => {{
        fn __compile_check() {
             fn main($($pats)*) $(-> $ty)? { $body }
        }
        json::json!({
            "main": stringify!(
                fn main($($pats)*) $(-> $ty)? { $body }
            ),
            $("args": $args)?
        })
    }};
}

Adding streaming, in principle, does not contain an important logical part, it is just code from the documentation Rocket:
https://github.com/deep-foundation/rust-docker-isolation-provider/pull/7/files
https://rocket.rs/v0.5-rc/guide/responses/#async-streams

But all these changes in the “quality of life” were absolutely unimportant, because it was just bargaining.

The main problem was still one thing. To implement a provider, it must be able to create connections through DeepClient (which I sincerely did not understand, because I always associated handlers with logic functions that simply receive and return data). And I stubbornly refused to make a DeepClient for the Rust provider, because such a (generated Hasura) darkness I haven't seen it yet. And in general the idea is strange.

The provider is a small server whose logic is literally in a couple of dozen lines of code. But not DeepClient, there’s just tons of monotonous code (for obvious reasons). That’s why I tried so hard to convince the guys that you definitely don’t need it.

In short, everyone stood their ground, justifying this by the early stage of development, but in the end they agreed on a ridiculous compromise:
I am remaking the provider for user-invisible compilation in WASM and adding js! macro for (oddly enough) js inserts directly in the Rust handler. \

According to the first idea, it should have looked something like this:

pub async fn add(a: i32, b: i32) -> i32 {
  js!([a: i32, b: i32] -> i32 {
      return a + b;
  })
  .await
}

I showed the code, this approach was approved, and I began to implement it. Although it is worth noting that now the macro is very ugly, for some reason all the captures are explicit, and even with an indication of the type (fortunately this will change later), and even the inlined javascript is always asynchronous. I decided not to change this for now, since the main functionality is still access to the client.
Here here on discord You can read more about the development process of this

Stage 4: Depression

The development of this plan was the saddest. Not only that Denowhich I assigned the role of WASM runtime, simply did not start due to the lack of support for it in apollo-clientand when replacing it with a node, unpleasant errors appeared in the implementations ESM modules.
In general, then I suffered exactly like that, but I got the job done.

It was very sad to say goodbye to rust-script, because now I had to manually use wasm-packand make the script on the node the entry point.

The logic was still in a couple of the most important lines:

// шаблоном теперь выступает целый готовый проект, который каждый раз копируется для нового хэндлера
fs_extra::dir::copy(env::current_dir()?.join("template"), &dir, &options())?;

let dir = dir.join("template");
fs::write(dir.join("src/lib.rs"), expand(TEMPLATE, ["#{main}", &code]))?;

macro_rules! troo {
    // этот макрос вызывает указанную программу с нужными аргументами,
    // которые можно удобно передать как просто строками, так и переменными
}

let _ = troo! { "wasm-pack" => "build" "--target" "nodejs" "--dev" dir };
let _ = troo! { "npm" => "install" "-g" "@deep-foundation/deeplinks" };

let out = troo! {
    "node" => dir.join("mod.mjs") data.get()
};
Ok(String::from_utf8(out)?)

The implementation details are conveniently spread over two commits:
https://github.com/deep-foundation/rust-docker-isolation-provider/pull/8/commits/290118569419281322d5754ecf7014d5c3a864dd
https://github.com/deep-foundation/rust-docker-isolation-provider/pull/8/commits/5983efb8dea3a62c3106e825427391ec4f29f3b2

But that's not the saddest thing. The worst thing was constantly changing the provider to match imaginary standard.

Moreover, this was not done all at once; the Deep developers themselves do not really know them yet, so they had to do everything during testing already in the deep environment.

Here are some of them:

  • The port in docker is not specified via p XXXX:YYYYand through p XXXX:XXXX -e PORT=XXXX – OK, the variable is not difficult to count

  • Data is sent to the handler in the format { "params": ... } which I completely forgot about – okay, wrapped

  • It turns out in "rejected": ... there may be not only a user error, but also a compilation error (etc.) – ok, let's shovel the system

  • They also insisted that DeepClient was passed immediately ready to the provider, which did not very nicely break the current system, since it should have allowed testing without a client instance.
    Therefore, it was originally transmitted precisely jwt: Option<String> and then the client was created by the user through the function deep.
    It would seem – replace the logic a little (just like I did here) and that's the end of it. But then the problem of incomplete specialization in Rust immediately follows (there is no implementation for JsValue Serialize). In general, the solution is as expected – you just need to fork the repository wasm-bidngen And implement the things we need right there.
    And the abundance of such things destroyed all my plans for a small beautiful implementation of the provider.

  • They also insisted that the handler arguments, which I simply accepted in order, were now passed as a structure.

    // было (да, теперь в хэндлере не функция, а лямбда)
    async |((a, b), deep): ((i32, i32), _)| -> i32
    // стало
    |Ctx { data: (a, b), deep, .. }: Ctx<(i32, i32)>| -> i32
    
    

Also during this period I implemented various more extensive quality of life changes:

  • We had to add caching from scratch, the logic of which changed twice. At first it was a regular implementation built on this crate. Then, in addition to caching identical handlers (by the way, this is also a funny part of the provider interface, it itself must remember the same handlers) we had to cache the assembly and their dependencies. Here we already had to rely on the build tools of Rust itself, namely workspaces.
    That is, in fact, all compiled handlers are waiting while one of them compiles the dependencies for\ all the others.

  • I also finally implemented adequate dependencies. Since I also provided them before rust-script. Now I have implemented a simple parser based on [winnow](https://crates.io/crates/winnow)which in turn is based on [nom](https://crates.io/crates/nom) (however, later I replaced it with chumskysince I had a positive experience of using it, and its errors from the box it's something with something)
    There is nothing unusual in the code. This syntax extension looks something like this:

    where cargo: {
      // код отсюда отправится прямо в `Cargo.toml` хэндлера
    }
    
    // остальной код хэндлера
    
    

    For those who are curious, the code is hidden
    here: https://github.com/deep-foundation/rust-docker-isolation-provider/blob/main/src/parse.rs

    Stage 5: Acceptance Conclusion

So, we have come to the final lines of our story about the path to creating a provider within Deep. Deep now has a working provider that allows you to run custom scripts on the grow. And I was once again convinced that it is better to write code that only you like only on your own Github.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *