background
u
h
7
i
w
+
]
N
<
C
{
C
;
z
<
7
Y
Y
&
+
w
$
S
)
v
a
:
e
4
Y
M
#

favicon
favicon
Reverier 的博客
 

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

Reverier-Xu at 2023-04-10 03:45:14 Development CC-BY-NC-SA 4.0

在后端处理题目方面,我经过长时间思索,最后感觉整个模型就是一个 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(())
}

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