Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[suiop] Add ci image build command #18136

Merged
merged 11 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)]
pei-mysten marked this conversation as resolved.
Show resolved Hide resolved
repo_name: String,
/// The path to the dockerfile within the source code repository given by `--repo_name`
#[arg(short, long)]
pei-mysten marked this conversation as resolved.
Show resolved Hide resolved
dockerfile: String,
/// Optional image tag to use, by default the image is tagged with code repo commit SHA & "latest"
#[arg(long)]
pei-mysten marked this conversation as resolved.
Show resolved Hide resolved
image_tag: Option<String>,
/// Optional image name, default to "app", only used if multiple images are built within one repo
#[arg(long)]
pei-mysten marked this conversation as resolved.
Show resolved Hide resolved
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
Loading