Написать X-docker-isolation-provider сложно — но не невозможно

Вы когда-нибудь чувствовали себя пионерами? Вот именно так я себя и ощущал, когда писал docker-isolation-provider для платформы ассоциативного программирования Deep.

Все было так: в один прекрасный день у нас на платформе связей решили — было бы славно портировать нашего бота в Deep. А для этого нужно было написать так называемые провайдеры.

Провайдеры нужны лишь для одной цели — дать возможность пользователю выполнять пользовательские хэндлеры на любом языке. Тогда я просто подумал, что было бы неплохо помочь парням, которые вероятно Rust никогда в жизни не видели. Вот тут список на npm: https://www.npmjs.com/search? q=docker-isolation-provider

Ох, как же я тогда ошибался…

Стадия 1: Отрицание

В качестве референса я использовал уже тогда готовый JavaScript провайдер. Делал я провайдер по согласованию с ребятами из Deep, однако без какой-то конкретной обратной связи. Код просили покороче, поэтому ничего лишнего я не добавлял, сугубо переписал на лад Js провайдера. Пришлось даже заарбузить [rust-script](https://rust-script.org/)

Вот сразу ссылка на pull request: https://github.com/deep-foundation/rust-docker-isolation-provider/pull/1

Чтобы разобраться, почему именно такой котд, надо понять, что именно он должен уметь, чтобы его можно было назвать тем самым сакральным X-docker-isolation-provider. Для начала я полез глядеть уже готовый провайдер для JS

Вот такая табличка встречает нас на GitHub страничке. В коде в принципе то же самое.

img

img

Внимание конечно привлекает и Docker в названии, по докер файлу понятно, что образ просто запускается на машине пользователя, обеспечивая выполнение кода, который ему шлют на нужный ендпоинт.

Что же, тут пока больше ловить нечего, остальное можно было узнавать уже напрямую у разработчиков на их дискорд сервере. Только вот чтобы писать в каналах разработчиков, нужно хотя бы обладать ролью кадета — ладно, сделано.

image

image

Стадия 2: Гнев

Когда я вносил эти небольшие правки, меня как-то не особо волновало, что в названии есть докер. Но теперь в голове всё время крутился вопрос «Зачем вообще докер?».
Почему бы к примеру не использовать какой-нибудь WASM рантайм с одной стандартизированной точкой входа, тогда не пришлось бы наваливать каждый провайдер с нуля. На что вскоре получил ответ — «васм это для client handler». Ладно, меньше вопросов, подумал я, надо всего лишь доделать провайдер. Только чтобы двигаться дальше надо было узнать, насколько мой PR соответствует требованиям.

Сразу стало понятно, что никого из Deep особо интересовал не сам код, а лишь его функционал, поэтому я сосредоточился на нём.

В целом вся логика находилась в этих строчках, а остальное лишь код веб-сервера

fs::write(
    /* handler.rs */,
    format!(
        "fn main() -> Result<(), Box> {{ \\
            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()?;

Откуда сразу становится ясна основная задумка. Код хэндлера просто вставляется вместо{code}. Он должен соответствовать такому вот формату:

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

Вместо TYPE может стоять любой impl Deserialize (передаются туда params из прилетевшего json’a), а в место RET любой impl Serialize. Это позволяло легко принимать почти любые типы без усилий, благодаря serde.

Вот один из более поздних скриншотов Postman, но суть он отражает

img_1

img_1

Здесь также не было никакой валидации кода (а соответственно и бессмысленного парсинга — вся ответственность была на компиляторе).

По этой же причине принимается лишь один аргумент (это конечно можно переделать на процедурных макросах, но зачем, если функционал serde эту проблему решает).

В общем такой подход был не очень хорошо оценён, и мне пришлось очень долго пояснять, почему это лучше, чем слепое использование динамического json, как в js провайдере.
Но всё же оставалась ещё куча вопросов:

  • если провайдер не для пользователей, так зачем в нём изоляция и докеризация. Как минимум было бы неплохо разрешить использовать тулинг с машины пользователя (если у него уже стоит Rust)

  • почему такой странный формат получаемых и возвращаемых данных (позже мне это ещё аукнется)

  • и самое странное, я узнал, что не могу просто возвращать результат (на самом деле могу), потому что, как оказалось, я могу лишь вернуть связь, которая которая ссылается на созданные через DeepClient данные

Стадия 3: Торг

К этому времени я уже начал переделывать логику провайдера, постепенно готовя её к расширению, внеся два основных изменения. Для начала я вынес шаблон кода хэндлера в отдельный файл, потому что достаточно увеличился в размерах.

Почему? Потому что потребовалась система, которая позволяла бы возвращать Result (или не возвращать).

Подробнее на это можно глянуть тут

Но если разбирать вкратце, всё это сводится к такому коду:

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

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

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

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

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

Также я добавил стриминг всего stderr (включая ошибки компиляции и обычные eprint’ы) на /stream ендпоинт и соответственно небольшой макрос, который позволял бы преобразовывать код в нужный формат для тестирования провайдера.

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)?
        })
    }};
}

Добавление стриминга в принципе не несёт в себе важной логической части, там просто код из документации Rocket:
https://github.com/deep-foundation/rust-docker-isolation-provider/pull/7/files
https://rocket.rs/v0.5-rc/guide/responses/#async-streams

Но все эти изменения «качества жизни» были абсолютно не важны, потому что, то был лишь торг.

Главная проблема по прежнему была в одной вещи. Чтобы реализовать провайдер, он должен уметь создавать связи через DeepClient (что я искренне не понимал, потому что всегда ассоциировал хэндлеры с функциями по логике работы, которые просто принимают и возвращают данные). А я упорно отказывался делать DeepClient для Rust провайдера, потому что такого (сгенерированного Хасурой) мрака я ещё не видывал. Да и в целом идея странная.

Провайдер — это небольшой сервер, логика которого находится буквально в паре десятков строк кода. Но не DeepClient, там просто тонны однообразного кода (по понятным причинам). Поэтому я так старательно старался убедить ребят, что оно вам точно не надо.

Короче все стояли на своём, оправдывая это ранним этапом разработки, но по итогу сошлись на смешном компромиссе:
Я переделываю провайдер под незаметную для юзера компиляцию в WASM и добавля js! макрос для (как ни странно) js вставок прямо в Rust хэндлере. \

Выглядеть это по первой задумке должно было как-то так:

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

Я показал код, такой подход был одобрен, и я принялся за реализацию. Хотя стоит отметить, что сейчас макрос очень уродлив, почему-то все захваты явные, да ещё и с указанием типа (благо потом это изменится), да ещё и инлайнутый javascript всегда асинхронный. Это я пока решил не менять, так как всё равно основной функционал это доступ к клиенту.
Вот тут в дискорде можно почитать подробнее о процессе разработки этого

Стадия 4: Депрессия

Разработка этого замысла была самой грустной. Мало того что Deno, на который я возлагал роль WASM рантайма, просто не завёлся из-за отсутствия его поддержки в apollo-client, так ещё и с заменой его на ноду вылезли неприятные ошибки в реализациях ESM модулей.
В общем тогда я конкретно так потерпел, но дело сделал.

Было очень грустно прощаться с rust-script, потому что теперь приходилось мануально использовать wasm-pack, а точкой входа так вообще сделать скрипт на ноде.

Логика по прежнему находилась в паре самых важных строк:

// шаблоном теперь выступает целый готовый проект, который каждый раз копируется для нового хэндлера
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)?)

Детали реализации удобно растянулись на двух коммитах:
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

Но не это самое грустно. Хуже всего было постоянно менять провайдер, чтобы он соответствовал мнимому стандарту.

Причём делалось это ещё и не всё сразу, разработчики Deep’a сами ещё не особо их знают, поэтому приходилось всё делать во время тестирования уже в среде дипа.

Вот некоторые из них:

  • Порт в докере указывается не через p XXXX:YYYY, а через p XXXX:XXXX -e PORT=XXXX — ладно, переменную не трудно считать

  • Данные в хэндлер передаются в формате { "params": ... } о чём я совершенно забыл — ладно, обернул

  • Оказывается в "rejected": ... может быть не только ошибка юзера, но и ошибка компиляции (и тп) — ок, перелопатим систему

  • Ещё настояли на том, чтобы DeepClient передавался сразу готовым в провайдер, что не очень приятно нарушало текущую систему, так как она должна была допускать тестирование без экземпляра клиента.
    Поэтому изначально передавался именно jwt: Option, а затем клиент создавался юзером через функцию deep.
    Казалось бы — замени чутка логику (прямо как я сделал тут) и дело с концом. Но тогда сразу же вытекает проблема незаконченности специализации в Rust (для JsValue нет реализации Serialize). В целом решение ожидаемое — нужно просто сфоркнуть репозиторий wasm-bidngen и реализовать нужные нам штуки прямо там.
    И вот обилие подобных вещей и уничтожило все мои планы по поводу маленькой красивой реализации провайдера.

  • Также настояли, чтобы аргументы хэндлера, которые я принимал просто по порядку, теперь передавались в виде структуры.

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

Также в этот период я реализовал различные более обширные quality of life изменения:

  • Пришлось с нуля добавить кэширование, логика которого дважды менялась. Сначала это была обычная реализация, построенная на этом крейте. Затем помимо кэширования одинаковых хэндлеров (это кстати тоже смешная часть интерфейса провайдера, он сам должен запоминать одинаковые хэндлеры) пришлось кэшировать сборку и их зависимостей. Тут уже пришлось положиться на средства сборки самого Rust, а именно на workspace’ы.
    То есть по факту все компилируемые хэндлеры находятся в ожидании, пока один из них компилирует зависимости для\ всех остальных.

  • Также я наконец реализовал адекватные зависимости. Так как их раньше тоже предоставлял rust-script. Теперь же я реализовал простой парсер, основанный на [winnow](https://crates.io/crates/winnow), который в свою очередь основан на [nom](https://crates.io/crates/nom) (однако позже я всё же заменил его на chumsky, так как у меня случился положительный опыт использования, а его ошибки из коробки это что-то с чем-то)
    В коде ничего необычного. Выглядит это расширение синтаксиса как-то так:

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

    Кому любопытно, код прячется
    тут: https://github.com/deep-foundation/rust-docker-isolation-provider/blob/main/src/parse.rs

    Стадия 5: Принятие Вывод

Итак, мы подошли к завершающим строкам нашего повествования о пути к созданию провайдера в рамках Deep. Теперь у Deep есть работающий провайдер, который позволяет исполнять пользовательские скрипты на расте. А я лишний раз убедился что писать код, который нравится только тебе лучше только у себя на гитхабе.

Habrahabr.ru прочитано 2689 раз