📌

Rustでdocker-compose.ymlのコンテナ全部git pullするCLI

2021/10/29に公開

概要

ここ最近RustでWebApp作るようにトライしてたけど
業務時間後の少ない時間で作っていくにはかなりタフなことを体感したので
使い慣れたGolangで行くことを決めた👴🏻

...が、せっかくチュートリアル触ったし何か書きたい・・

ということでCLIをRustで書いてみる

作ったもの

業務で取り扱ってる
docker-compose.ymlではたくさんサービスが入ってる
数日すると各サービスのgitはリモートと差分がたくさん出た状態になり
毎度docker compose exec xxx git pull originしなきゃいけなくて面倒

なので、「サービス一覧とってきて非同期で勝手にgit pullしてくれるコマンド」をRustで作ることにした

結論

お仕事ドタバタであんま時間ないので
後で振り返れるように書いたコードだけ貼っとく

ディレクトリ構成こんな感じ

$ tree -L 3
.
├── Cargo.lock
├── Cargo.toml
├── src
│   ├── docker_compose
│   │   ├── file.rs
│   │   └── git.rs
│   ├── docker_compose.rs
│   └── main.rs
└── tests
    └── cli.rs

依存関係

Cargo.toml
[package]
name = "ucwork-cli"
version = "0.1.0"
edition = "2018"
license = "MIT OR Apache-2.0"
description = "A tool ucwork local development"

[dependencies]
structopt = "0.3.22"
anyhow = "1.0"
log = "0.4.14"
env_logger = "0.9.0"
serde = "1.0"
serde_yaml = "0.8"
indicatif = "0.16.2"

[dev-dependencies]
assert_cmd = "2.0"
predicates = "2.0"
assert_fs = "1.0"
tempfile = "3"

バイナリファイル

  1. docker-compose.ymlをパースしてサービス名一覧取得
  2. 引数にサービス名があれば存在確認してなければエラー
    3. 存在すればそのサービスだけgit pull
  3. 引数ない場合サービス全部git pull
    5. 全部非同期で実行
    6. プログレスバーで進捗表示
main.rs
mod docker_compose;

use anyhow::{Context, Result};
use indicatif::ProgressBar;
use log::info;
use std::sync::mpsc;
use std::thread;
use structopt::StructOpt;

pub use crate::docker_compose::file;
pub use crate::docker_compose::git;

#[derive(StructOpt)]
struct Cli {
    #[structopt()]
    service_name: Option<String>,
}

fn main() -> Result<()> {
    env_logger::init();
    info!("start cli");

    let services: Vec<String> =
        file::get_service_names().with_context(|| format!("failed to get service names"))?;

    let args: Cli = Cli::from_args();
    if let Some(v) = args.service_name {
        let found_service = &services
            .iter()
            .find(|&s| *s == v)
            .with_context(|| format!("Request service name {} not found", v))?;
        git::pull_origin(&found_service.to_string())
            .with_context(|| format!("failed to pull origin"))?;
    } else {
        let (tx, rx) = mpsc::channel();

        let pb = ProgressBar::new(services.len() as u64);
        for s in services {
            let tx = mpsc::Sender::clone(&tx);
            thread::spawn(move || -> Result<()> {
                git::pull_origin(&s).with_context(|| format!("failed to pull origin"))?;
                tx.send(s).unwrap();
                Ok(())
            });
        }
        drop(tx);

        for received in rx {
            pb.println(format!("[+] finished #{}", received));
            pb.inc(1);
        }
        pb.finish_with_message("done");
    }

    info!("end cli");
    Ok(())
}

ディレクトリ内のmodule宣言

docker_compose.rs
pub mod file;
pub mod git;

docker-compose.ymlファイルを操作するModule

docker_compose/file.rs
use anyhow::{Context, Result};
use std::fs::File;

pub fn get_service_names() -> Result<Vec<String>> {
    let target_file_path = "docker-compose.yml";
    let f = File::open(target_file_path)
        .with_context(|| format!("Target file {} not found", target_file_path))?;
    let yaml_serde: serde_yaml::Value = serde_yaml::from_reader(f)
        .with_context(|| format!("Parse {} file error", target_file_path))?;
    let yaml_serde_tuple = yaml_serde["services"]
        .as_mapping()
        .with_context(|| format!("Services as mapping error"))?;
    Ok(yaml_serde_tuple
        .iter()
        .map(|service| service.0.as_str().unwrap_or_default().to_string())
        .collect::<Vec<String>>())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "Target file docker-compose.yml not found")]
    fn test_get_service_names() {
        let _ = get_service_names().unwrap();
    }
}

docker composeを介してgit操作するModule

docker_compose/git.rs
use anyhow::{Context, Result};
use std::process::Command;

pub fn pull_origin(service_name: &String) -> Result<()> {
    Command::new("zsh")
        .arg("-c")
        .arg(format!(
            "docker compose exec {} git pull origin",
            service_name
        ))
        .output()
        .with_context(|| format!("docker compose {} exec error", service_name))?;
    Ok(())
}

何もかいてないけどIntegration testのファイル

tests/cli.rs
use assert_cmd::prelude::*; // Add methods on commands
use predicates::prelude::*; // Used for writing assertions
use std::process::Command; // Run programs

#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
    let mut cmd = Command::cargo_bin("ucwork-cli")?;

    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("failed to get service names"));

    Ok(())
}

あとは
cargo build --releaseして
生成された実行ファイル./target/release/ucwork-cli
docker-compose.ymlの存在するディレクトリで実行すれば(Mac環境なら)動くはず
※バイナリのファイル名は各々違うと思うのでご注意を
※いらないuseも色々残ってます🙈

まとめ

ちょっとしたコマンドツールなのにめっちゃ苦労した・・・
とりあえずOptionとResultはだいぶ掴んできた

ちょっとでもif文やmapなどのClosure絡むと
Ownership, Lifetimeあたりがオコしてくる
色々書いてみて修行あるのみやな・・・・・

テストコードすらろくに書いてないけど、
Rustチャンスを見つけてちょっとずつ書いてこう🎃

Discussion