mod archive;
mod discovery;
mod dry_run;
mod output;
mod plan;
mod types;
mod wait;
use super::{
api_commands::api_request,
args::CliOptions,
auth::read_or_login_token,
check::run_check,
errors::{Result, agent_error, print_json},
project::{encode_component, nested_string},
};
use archive::create_archive_base64;
pub(crate) use discovery::discover_deploy_projects;
use dry_run::print_deploy_dry_run;
use output::{print_deploy_result, print_workspace_deploy_results};
use plan::create_deploy_plan;
use reqwest::Method;
use serde_json::{Value, json};
use std::{
path::Path,
process::{Command, Stdio},
};
use types::{DeployPlanProject, WorkspaceDeployResult};
use wait::wait_for_workspace_builds;
pub(crate) fn deploy(project_dir: &Path, cli: &CliOptions) -> Result<()> {
match cli.args.first().map(String::as_str) {
Some("list") => return list_deploys(cli),
Some("show") => return show_deploy(cli),
Some("cancel") => return cancel_deploy(cli),
_ => {}
}
let projects = discover_deploy_projects(project_dir)?;
if projects.is_empty() {
return Err(agent_error(
"missing_project_contract",
"No tovuk.toml was found.",
"Create a tovuk.toml in each service directory, run `tovuk new <path> --template <template>`, or pass a project path.",
cli.output.json,
));
}
let token = read_or_login_token(cli)?;
if cli.deployment.dry_run {
return print_deploy_dry_run(project_dir, &projects, cli, &token);
}
let plan = create_deploy_plan(&projects, cli, &token)?;
let mut results = deploy_projects(&plan, cli, &token)?;
if cli.deployment.wait {
wait_for_workspace_builds(cli, &token, &mut results)?;
}
if results.len() == 1 {
print_deploy_result(&results[0].response, cli)
} else {
print_workspace_deploy_results(project_dir, &results, cli)
}
}
fn list_deploys(cli: &CliOptions) -> Result<()> {
let token = read_or_login_token(cli)?;
let route = deploy_list_route(cli);
let response = api_request(cli, Method::GET, &route, Some(&token), None)?;
print_json(&response)
}
fn show_deploy(cli: &CliOptions) -> Result<()> {
let deploy_id = deploy_id_arg(cli, "show")?;
let token = read_or_login_token(cli)?;
let response = api_request(
cli,
Method::GET,
&deploy_show_route(&deploy_id),
Some(&token),
None,
)?;
print_json(&response)
}
fn cancel_deploy(cli: &CliOptions) -> Result<()> {
let deploy_id = deploy_id_arg(cli, "cancel")?;
let token = read_or_login_token(cli)?;
let response = api_request(
cli,
Method::POST,
&deploy_cancel_route(&deploy_id),
Some(&token),
None,
)?;
print_json(&response)
}
fn deploy_id_arg(cli: &CliOptions, command: &str) -> Result<String> {
cli.args
.get(1)
.cloned()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
agent_error(
"deploy_id_required",
"Deploy id is required.",
format!(
"Pass a deploy id to `tovuk deploy {command} <deploy_id> --json` from `tovuk deploy list --json`, `tovuk deploy --wait --json`, `tovuk service show <service> --json`, or build logs."
),
cli.output.json,
)
})
}
fn deploy_list_route(cli: &CliOptions) -> String {
let suffix = deploy_page_query(cli);
if cli.service.is_empty() {
return format!("/v1/deploys{suffix}");
}
format!(
"/v1/services/{}/deploys{suffix}",
encode_component(&cli.service)
)
}
fn deploy_show_route(deploy_id: &str) -> String {
format!("/v1/deploys/{}", encode_component(deploy_id))
}
fn deploy_cancel_route(deploy_id: &str) -> String {
format!("/v1/deploys/{}/cancel", encode_component(deploy_id))
}
fn deploy_page_query(cli: &CliOptions) -> String {
let mut params = Vec::new();
if !cli.limit.is_empty() {
params.push(format!("limit={}", encode_component(&cli.limit)));
}
if !cli.cursor.is_empty() {
params.push(format!("cursor={}", encode_component(&cli.cursor)));
}
if params.is_empty() {
String::new()
} else {
format!("?{}", params.join("&"))
}
}
fn deploy_projects(
plan: &[DeployPlanProject],
cli: &CliOptions,
token: &str,
) -> Result<Vec<WorkspaceDeployResult>> {
let mut results = Vec::new();
let workspace_deploy = plan.len() > 1;
if workspace_deploy && !cli.output.json {
println!("deploying {} projects", plan.len());
}
for item in plan {
if workspace_deploy && !cli.output.json {
println!("checking {}", item.project.relative);
}
let response = deploy_project(&item.project.dir, cli, token)?;
if workspace_deploy && !cli.output.json {
println!(
"{} queued {}",
item.project.relative,
nested_string(&response, &["build_job", "id"])
);
println!(
"{} url {}",
item.project.relative,
nested_string(&response, &["service", "url"])
);
}
results.push(WorkspaceDeployResult {
project: item.project.clone(),
response,
final_build: None,
});
}
Ok(results)
}
fn deploy_project(project_dir: &Path, cli: &CliOptions, token: &str) -> Result<Value> {
let report = run_check(project_dir);
if !report.ok {
let instruction = report
.checks
.iter()
.find(|check| !check.ok)
.and_then(|check| check.agent_instruction.clone())
.unwrap_or_else(|| "Fix the failed checks and retry.".to_owned());
return Err(agent_error(
"check_failed",
"Tovuk check failed.",
instruction,
cli.output.json,
));
}
let body = json!({
"config": report.config,
"commit_sha": git_commit_sha(project_dir),
"source_archive_base64": create_archive_base64(project_dir, cli.output.json)?,
});
api_request(cli, Method::POST, "/v1/deploy", Some(token), Some(body))
}
fn git_commit_sha(project_dir: &Path) -> Option<String> {
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(project_dir)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::{deploy_cancel_route, deploy_id_arg, deploy_list_route, deploy_show_route};
use crate::cli::args::CliOptions;
#[test]
fn deploy_show_and_cancel_use_target_routes() {
assert_eq!(
deploy_show_route("deploy_0123456789abcdef0123"),
"/v1/deploys/deploy_0123456789abcdef0123"
);
assert_eq!(deploy_show_route("deploy/id"), "/v1/deploys/deploy%2Fid");
assert_eq!(
deploy_cancel_route("deploy_0123456789abcdef0123"),
"/v1/deploys/deploy_0123456789abcdef0123/cancel"
);
assert_eq!(
deploy_cancel_route("deploy/id"),
"/v1/deploys/deploy%2Fid/cancel"
);
}
#[test]
fn deploy_list_uses_account_or_service_route() {
let account_cli = CliOptions {
command: "deploy".to_owned(),
args: vec!["list".to_owned()],
limit: "10".to_owned(),
cursor: "next/page".to_owned(),
..CliOptions::default()
};
assert_eq!(
deploy_list_route(&account_cli),
"/v1/deploys?limit=10&cursor=next%2Fpage"
);
let service_cli = CliOptions {
command: "deploy".to_owned(),
args: vec!["list".to_owned()],
service: "api/service".to_owned(),
..CliOptions::default()
};
assert_eq!(
deploy_list_route(&service_cli),
"/v1/services/api%2Fservice/deploys"
);
}
#[test]
fn deploy_lifecycle_commands_require_deploy_id() {
let cli = CliOptions {
command: "deploy".to_owned(),
args: vec!["cancel".to_owned()],
..CliOptions::default()
};
let error_message = deploy_id_arg(&cli, "cancel")
.err()
.map(|error| error.to_string());
assert_eq!(error_message.as_deref(), Some("Deploy id is required."));
}
}