实现一个基于Git的存储和自动构建服务

警告
本文最后更新于 2023-04-10,文中内容可能已过时。

在后端处理题目方面,我经过长时间思索,最后感觉整个模型就是一个 Git + CI/CD。出题人通过 Git 将题目部署上去,然后平台自动根据对应的 Checker 类型来执行构建操作,并根据构建结果来确认题目状态,在选手访问题目时,就可以直接提供服务。这样一来,整套题目服务系统就能够高度自动化运作,出题人只需要写好build脚本,设置一下题目相关的配置文件然后推送上去就可以了。

但是…… Rust下面没有能够直接提供远程Git服务的crate啊,有一个libgit2的绑定,libgit2本来就没有服务端功能;有一个gixoide,大部分功能还在alpha……

于是我根据Git文档手撸了一份HTTP协议处理。好,接下来是另一个大问题,内部协议怎么办?我总不能从头开始实现一个git吧…… 遇事不决看看现有方案怎么做的。于是我打开了Gitea。Gitea告诉我,你可以 subprocess.popen(“git”) ……

传输协议

首先实现拉取与推送操作,这样出题人可以直接使用git和比赛平台上的仓库进行交互。根据Git内部传输协议,一次远程交互过程从数据文件协商开始。以git-fetch为例,客户端首先向服务端发送一个 HTTP GET 请求:

=> GET $GIT_URL/info/refs?service=git-upload-pack
001e# service=git-upload-pack
00e7ca82a6dff817ec66f44342007202690a93763949 HEAD multi_ack thin-pack \
    side-band side-band-64k ofs-delta shallow no-progress include-tag \
    multi_ack_detailed no-done symref=HEAD:refs/heads/master \
    agent=git/2:2.1.1+github-607-gfba4028
003fca82a6dff817ec66f44342007202690a93763949 refs/heads/master
0000

在第一次交互中,客户端向服务端请求数据文件列表,服务端会通过git-upload-pack进程查询仓库的状态,并将服务端拥有的数据对象以列表的形式组织起来,发送给客户端。第一行文件的末尾还会特殊附加上服务端所支持的特性列表。

在获取服务端的数据文件列表之后,客户端开始查询本地的仓库状态,对比服务端的数据对象列表和本地的差异,然后将其整合起来。整合完毕之后,客户端会向服务端发送第二个 HTTP POST 请求:

=> POST $GIT_URL/git-upload-pack HTTP/1.0
0032want 0a53e9ddeaddad63ad106860237bbf53411d11a7
0032have 441b40d833fdfa93eb2908e52742248faf0ee993
0000

在这个请求中客户端通过want和have提示词告诉服务器哪些文件是本地已经拥有的,哪些是需要服务端发送的。在协议的最后有一个 0000 作为协议结尾,提示服务器可以开始发送数据对象了。服务器接收完毕差异列表之后,就会开始压缩客户端所需要的数据对象,并在HTTP响应中将这些数据对象编码并传输给客户端。

客户端最终接收到了所需的数据对象,并将其解压到本地的数据对象数据库中,然后根据最后一次提交的“tree”信息将当前版本的数据对象检出到工作目录中。

比赛平台的Git实现主要关注在底层HTTP协议的支持上,平台负责将HTTP协议中的Git协议数据包提取出来,并以数据流的形式写入Git进程,然后将进程返回的二进制数据流写回到HTTP响应之中。Git服务所支持的额外特性则取决于服务器上的Git版本支持。

实现完毕Git传输协议之后,接下来需要将Git仓库中的当前版本文件检出到工作目录中,以便于后续持续集成/持续部署模块的工作。

Git仓库中的HEAD文件指向当前仓库的最新提交记录,可以从这里拿到提交记录所对应的tree,并通过这个tree所关联的数据对象来恢复工作区:

pub fn checkout_head(&self, dst_path: impl AsRef<Path>)
        -> anyhow::Result<()> {
    let dst_path = dst_path.as_ref();
    let git_path = self.path.clone();
    let mut index = gix_index::File::at(
        git_path.join("index"),
        Sha1,
        Default::default()
    )?;
    let odb = gix::odb::at(git_path.join("objects"))?
        .into_inner()
        .into_arc()?;
    let _outcome = gix_worktree::checkout(
        &mut index,
        dst_path,
        move |oid, buf| odb.find_blob(oid, buf),
        &mut progress::Discard,
        &mut progress::Discard,
        &AtomicBool::default(),
        gix_worktree::checkout::Options {
            overwrite_existing: true,
            ..Default::default()
        },
    )?;
    Ok(())
}

在git操作上,我选了gitoxide库来查询HEAD所对应的提交记录,并根据提交记录来将整个工作区文件恢复至 dst_path 中。选gitoxide的一大原因是纯rust实现,就个人洁癖而言我还是很愿意费点力气尽力减少二进制依赖的。

持续集成/持续部署

实现完成Git文件存储模块之后,接下来要实现持续集成/持续部署模块来与之相配合,共同完成题目的存储、发布工作。由于题目的构建工作可能耗时很长,因此将其过程放在某个HTTP请求处理过程中是不合适的。同时,构建过程可能会较大的消耗服务器资源,因此需要控制题目构建的资源消耗。

在实现方案中使用了Redis提供的消息队列功能来处理题目构建请求。当出题人在平台上请求构建题目时,这个构建请求会被放入Redis的消息队列中。在服务器启动时,会初始化一个单独的线程持续监听消息队列,如果消息队列中有新的构建请求,那么就停止监听并取出这个请求,然后调用题目类型对应的构建代码来处理题目仓库中的文件,根据出题人设置好的配置文件将题目附件、容器等必要组件构建好,存储在stable文件夹中备用。构建完毕之后,构建线程会重新回到监听消息队列的状态,并持续处理之后的构建请求。

这样就可以将构建过程消耗的服务器资源控制在单个题目资源上,不会出现题目构建请求过多将服务器硬件资源消耗殆尽,平台无法对外提供服务的情况。

构建线程大概长这样:

pub async fn start_build_worker(mut cache: BuilderCache)
        -> anyhow::Result<()> {
    tokio::spawn(async move {
        loop {
            let challenge = cache.get_task().await?;
            let mut checker = open_checker(&challenge).await?;
            match checker.build().await {
                Ok(_) => {
                    debug!("challenge built: {}:{}", challenge.id, challenge.name);
                }
                Err(err) => {
                    error!("failed to build challenge: {}", err);
                    continue;
                }
            }
        }
    });
    Ok(())
}

open_checker 函数用来根据challenge类型来构造checker,然后调用checker对应的build函数来进行构建操作。不同的题目类型构建方式也不一样,这里通过工厂模式实现了逻辑解耦,想实现一个新的题目类型只要按照要求实现一下对应的trait就可以了。

一个简单的附件题目构建函数例子:

async fn build(&mut self) -> anyhow::Result<()> {
    self.bucket.working.clean().await?;
    self.bucket.checkout_to_working().await?;
    self.bucket.working.lock().await?;
    let base_path = self.bucket.working.path();
    let config_file = base_path.join("config.toml");
    let config = read_config(&config_file)?;
    let mut check_flag = true;
    for file in config.provided {
        if !base_path.join(file).exists() {
            check_flag = false;
            break;
        }
    }
    self.bucket.working.unlock().await?;
    if check_flag {
        self.bucket.stabilize().await?;
    }
    Ok(())
}

由于静态附件类题目只需要检查提供给选手的文件是否有误,所以只需要这样就可以了。

0%