Skip to content

Commit

Permalink
[suiop] Add ci image build command (#18136)
Browse files Browse the repository at this point in the history
## Description 

Support building image from suiop CLI
With this, you can build and push images to our repo. The CLI tool
supports building from branch, commit or tag.

## Test plan 
you can build from branch or commit from suiop CLI
<img width="1456" alt="image"
src="https://github.com/MystenLabs/sui/assets/147538877/fc4b887e-a4e0-4a5a-bdbc-3099202e155b">

you can also query the image build history:
<img width="1090" alt="image"
src="https://github.com/MystenLabs/sui/assets/147538877/e0bd0847-0a8a-46a7-a264-c68bbcfaa7b1">

---

## Release notes

Check each box that your changes affect. If none of the boxes relate to
your changes, release notes aren't required.

For each box you select, include information after the relevant heading
that describes the impact of your changes that a user might notice and
any actions they must take to implement updates.

- [ ] Protocol: 
- [ ] Nodes (Validators and Full nodes): 
- [ ] Indexer: 
- [ ] JSON-RPC: 
- [ ] GraphQL: 
- [ ] CLI: 
- [ ] Rust SDK:

---------

Co-authored-by: jk jensen <jk@mystenlabs.com>
  • Loading branch information
pei-mysten and after-ephemera authored Jun 19, 2024
1 parent 21b533d commit d839bae
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 9 deletions.
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions crates/suiop-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ edition = "2021"
license = "Apache-2.0"
name = "suiop-cli"
publish = false
version = "0.2.2"
version = "0.2.3"

[lib]
name = "suioplib"
Expand All @@ -33,18 +33,19 @@ open = "5.1.2"
prettytable-rs.workspace = true
rand.workspace = true
regex.workspace = true
reqwest = { workspace = true, features = [
reqwest = {workspace = true, features = [
"rustls-tls",
"json",
], default-features = false }
], default-features = false}
semver.workspace = true
serde = { workspace = true, features = ["derive"] }
serde = {workspace = true, features = ["derive"]}
serde_json.workspace = true
serde_yaml.workspace = true
sha2 = "0.10.6"
spinners.workspace = true
strum.workspace = true
tokio = { workspace = true, features = ["full"] }
tabled.workspace = true
tokio = {workspace = true, features = ["full"]}
toml_edit.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
Expand Down
272 changes: 272 additions & 0 deletions crates/suiop-cli/src/cli/ci/image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use crate::cli::lib::{get_api_server, get_oauth_token};
use anyhow::Result;

use chrono::{DateTime, Local, Utc};
use clap::{Parser, ValueEnum};
use colored::Colorize;
use serde::{self, Serialize};
use std::{fmt::Display, str::FromStr};
use tabled::{settings::Style, Table, Tabled};
use tracing::debug;

#[derive(Tabled)]
struct BuildInfo {
name: String,
status: String,
#[tabled(rename = "Start Time (Local Time)")]
start_time: String,
#[tabled(rename = "End Time")]
end_time: String,
}

#[derive(Parser, Debug)]
pub struct ImageArgs {
#[command(subcommand)]
action: ImageAction,
}

#[derive(ValueEnum, Clone, Debug)]
#[clap(rename_all = "lowercase")]
pub enum RefType {
Branch,
Tag,
Commit,
}

impl Serialize for RefType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(match self {
RefType::Branch => "branch",
RefType::Tag => "tag",
RefType::Commit => "commit",
})
}
}

impl Display for RefType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RefType::Branch => write!(f, "branch"),
RefType::Tag => write!(f, "tag"),
RefType::Commit => write!(f, "commit"),
}
}
}

#[derive(clap::Subcommand, Debug)]
pub enum ImageAction {
#[command(name = "build")]
Build {
/// The name of the git repository within the mystenlabs org
#[arg(short, long)]
repo_name: String,
/// The path to the dockerfile within the source code repository given by `--repo_name`
#[arg(short, long)]
dockerfile: String,
/// Optional image tag to use, by default the image is tagged with code repo commit SHA & "latest"
#[arg(long)]
image_tag: Option<String>,
/// Optional image name, default to "app", only used if multiple images are built within one repo
#[arg(long)]
image_name: Option<String>,
/// Optioanl reference type, default to "branch"
#[arg(long)]
ref_type: Option<RefType>,
/// Optional reference value, default to "main"
#[arg(long)]
ref_val: Option<String>,
},
#[command(name = "query")]
Query {
#[arg(short, long)]
repo_name: String,
#[arg(short, long)]
limit: Option<u32>,
},
}

#[derive(serde::Serialize, Debug)]
struct RequestBuildRequest {
repo_name: String,
dockerfile: String,
image_tag: Option<String>,
image_name: Option<String>,
ref_type: Option<RefType>,
ref_val: Option<String>,
}

#[derive(serde::Serialize)]
struct QueryBuildsRequest {
repo_name: String,
limit: u32,
}

const ENDPOINT: &str = "/automation/image-build";

pub async fn image_cmd(args: &ImageArgs) -> Result<()> {
let token = get_oauth_token().await?;
debug!("token: {}", token.access_token);
send_image_request(&token.access_token, &args.action).await?;

Ok(())
}

#[derive(serde::Deserialize)]
struct JobStatus {
name: String,
status: String,
start_time: String,
end_time: Option<String>,
}

#[derive(serde::Deserialize)]
struct QueryBuildResponse {
pods: Vec<JobStatus>,
}

async fn send_image_request(token: &str, action: &ImageAction) -> Result<()> {
let req = generate_image_request(token, action);

let resp = req.send().await?;
debug!("resp: {:?}", resp);

let status = resp.status();

if status.is_success() {
match action {
ImageAction::Build {
repo_name,
dockerfile,
image_name,
image_tag,
ref_type,
ref_val,
} => {
let ref_type = ref_type.clone().unwrap_or(RefType::Branch);
let ref_val = ref_val.clone().unwrap_or("main".to_string());
let ref_name = format!("{}:{}", ref_type, ref_val);
let image_name = image_name.clone().unwrap_or("app".to_string());
let image_tag = image_tag.clone().unwrap_or("".to_string());
let mut image_info = image_name;
if !image_tag.is_empty() {
image_info += &format!(":{}", image_tag);
}
println!(
"Requested built image for repo: {}, ref: {}, dockerfile: {}, image: {}",
repo_name.green(),
ref_name.green(),
dockerfile.green(),
image_info.green()
);
let json_resp = resp.json::<JobStatus>().await?;
println!("Build Job Status: {}", json_resp.status.green());
println!("Build Job Name: {}", json_resp.name.green());
println!(
"Build Job Start Time: {}",
utc_to_local_time(json_resp.start_time).green()
);
}
ImageAction::Query {
repo_name,
limit: _,
} => {
println!("Requested query for repo: {}", repo_name.green());
let json_resp = resp.json::<QueryBuildResponse>().await?;
let job_statuses = json_resp.pods.into_iter().map(|pod| {
// Parse the string into a NaiveDateTime
let start_time = utc_to_local_time(pod.start_time);
let end_time = utc_to_local_time(pod.end_time.unwrap_or("".to_string()));

BuildInfo {
name: pod.name,
status: pod.status,
start_time,
end_time,
}
});
let mut tabled = Table::new(job_statuses);
tabled.with(Style::rounded());

let tabled_str = tabled.to_string();
println!("{}", tabled_str);
}
}
Ok(())
} else {
Err(anyhow::anyhow!(
"Failed to run image build request. Status: {} - {}",
status,
resp.text().await?
))
}
}

fn utc_to_local_time(utc_time: String) -> String {
if utc_time.is_empty() {
return utc_time;
}
let utc_time_result =
DateTime::<Utc>::from_str(&format!("{}T{}Z", &utc_time[..10], &utc_time[11..19]));
if let Ok(utc_time) = utc_time_result {
let local_time = utc_time.with_timezone(&Local);
local_time.format("%Y-%m-%d %H:%M:%S").to_string()
} else {
utc_time.to_string()
}
}

fn generate_headers_with_auth(token: &str) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
);
headers
}

fn generate_image_request(token: &str, action: &ImageAction) -> reqwest::RequestBuilder {
let client = reqwest::Client::new();
let api_server = get_api_server();
let full_url = format!("{}{}", api_server, ENDPOINT);
debug!("full_url: {}", full_url);
let req = match action {
ImageAction::Build {
repo_name,
dockerfile,
image_name,
image_tag,
ref_type,
ref_val,
} => {
let req = client.post(full_url);
let body = RequestBuildRequest {
repo_name: repo_name.clone(),
dockerfile: dockerfile.clone(),
image_name: image_name.clone(),
image_tag: image_tag.clone(),
ref_type: ref_type.clone(),
ref_val: ref_val.clone(),
};
debug!("req body: {:?}", body);
req.json(&body).headers(generate_headers_with_auth(token))
}
ImageAction::Query { repo_name, limit } => {
let req = client.get(full_url);
let limit = (*limit).unwrap_or(10);
let query = QueryBuildsRequest {
repo_name: repo_name.clone(),
limit,
};
req.query(&query).headers(generate_headers_with_auth(token))
}
};
debug!("req: {:?}", req);

req
}
9 changes: 6 additions & 3 deletions crates/suiop-cli/src/cli/ci/mod.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

mod image;
mod key;

use anyhow::Result;
use key::key_cmd;
use image::{image_cmd, ImageArgs};
use key::{key_cmd, KeyArgs};

use clap::Parser;

use self::key::KeyArgs;

#[derive(Parser, Debug)]
pub struct CIArgs {
#[command(subcommand)]
Expand All @@ -20,11 +20,14 @@ pub struct CIArgs {
pub(crate) enum CIAction {
#[clap(aliases = ["k", "key"])]
Keys(KeyArgs),
#[clap(aliases = ["i", "image"])]
Image(ImageArgs),
}

pub async fn ci_cmd(args: &CIArgs) -> Result<()> {
match &args.action {
CIAction::Keys(keys) => key_cmd(keys).await?,
CIAction::Image(image) => image_cmd(image).await?,
}

Ok(())
Expand Down

0 comments on commit d839bae

Please sign in to comment.