use std::{
io::{IsTerminal, Write},
process::Command,
};
use anyhow::{Result, bail};
use dialoguer::{Confirm, theme::ColorfulTheme};
use serde::Deserialize;
use termcolor::{StandardStream, WriteColor};
use crate::{
constants::get_platform_management_api_url,
core::{hmac::AuthMode, http_client},
};
#[derive(Debug, Deserialize)]
struct ApplicationSummary {
name: Option<String>,
#[serde(rename = "gitRepository")]
git_repository: Option<String>,
}
#[derive(Debug, Deserialize)]
struct EnvironmentsResponse {
environments: Vec<EnvironmentRow>,
}
#[derive(Debug, Deserialize)]
struct EnvironmentRow {
name: String,
}
pub(crate) struct DeployTarget {
pub(crate) application_name: String,
pub(crate) repository: Option<String>,
pub(crate) environment_exists: Option<bool>,
pub(crate) existing_environments: Vec<String>,
}
pub(crate) fn normalize_repository(url: &str) -> String {
let mut s = url.trim().to_string();
if let Some(rest) = s.strip_prefix("git@") {
s = format!("https://{}", rest.replacen(':', "/", 1));
}
if let Some(rest) = s.strip_prefix("ssh://git@") {
s = format!("https://{rest}");
}
if let Some(rest) = s.strip_prefix("git://") {
s = format!("https://{rest}");
}
if let Some(rest) = s.strip_prefix("http://") {
s = format!("https://{rest}");
}
let s = s.trim_end_matches('/');
let s = s.strip_suffix(".git").unwrap_or(s);
s.to_lowercase()
}
pub(crate) fn local_origin_url() -> Option<String> {
let out = Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?.trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
pub(crate) fn resolve_target(
auth_mode: &AuthMode,
application_id: &str,
environment: &str,
manifest_repository: Option<&str>,
) -> DeployTarget {
let mut target = DeployTarget {
application_name: application_id.to_string(),
repository: manifest_repository.map(str::to_string),
environment_exists: None,
existing_environments: Vec::new(),
};
if auth_mode.is_hmac() {
return target;
}
let base = get_platform_management_api_url();
if let Ok(resp) =
http_client::get_with_auth(auth_mode, &format!("{base}/applications/{application_id}"))
{
if resp.status().is_success() {
if let Ok(app) = resp.json::<ApplicationSummary>() {
if let Some(name) = app.name.filter(|n| !n.is_empty()) {
target.application_name = name;
}
if let Some(repo) = app.git_repository.filter(|r| !r.is_empty()) {
target.repository = Some(repo);
}
}
}
}
if let Ok(resp) = http_client::get_with_auth(
auth_mode,
&format!("{base}/applications/{application_id}/environments"),
) {
if resp.status().is_success() {
if let Ok(envs) = resp.json::<EnvironmentsResponse>() {
let mut names: Vec<String> =
envs.environments.into_iter().map(|e| e.name).collect();
names.sort();
names.dedup();
target.environment_exists =
Some(names.iter().any(|n| n.eq_ignore_ascii_case(environment)));
target.existing_environments = names;
}
}
}
target
}
#[derive(Debug, PartialEq)]
pub(crate) enum Gate {
Proceed,
RepositoryMismatch {
local: String,
application: String,
},
EnvironmentMissing,
AskToCreateEnvironment,
}
pub(crate) fn gate(
target: &DeployTarget,
local_origin: Option<&str>,
create_environment: bool,
force: bool,
interactive: bool,
) -> Gate {
if !force {
if let (Some(local), Some(app)) = (local_origin, target.repository.as_deref()) {
if normalize_repository(local) != normalize_repository(app) {
return Gate::RepositoryMismatch {
local: local.to_string(),
application: app.to_string(),
};
}
}
}
match target.environment_exists {
Some(false) if !create_environment && !force => {
if interactive {
Gate::AskToCreateEnvironment
} else {
Gate::EnvironmentMissing
}
}
_ => Gate::Proceed,
}
}
pub(crate) fn confirm_target(
target: &DeployTarget,
release: &str,
environment: &str,
region: &str,
create_environment: bool,
force: bool,
stdout: &mut StandardStream,
) -> Result<()> {
match &target.repository {
Some(repo) => log_info!(
stdout,
"Deploying {} ({}) release {} -> {}/{}",
target.application_name,
repo,
release,
environment,
region
),
None => log_info!(
stdout,
"Deploying {} release {} -> {}/{}",
target.application_name,
release,
environment,
region
),
}
let interactive = std::io::stdin().is_terminal();
match gate(
target,
local_origin_url().as_deref(),
create_environment,
force,
interactive,
) {
Gate::Proceed => Ok(()),
Gate::RepositoryMismatch { local, application } => bail!(
"This checkout's origin is not the application's repository.\n \
here: {local}\n \
{name}: {application}\n\
You are probably in the wrong directory. Run from the application's checkout, \
or pass --force to deploy {name} from here anyway.",
name = target.application_name
),
Gate::EnvironmentMissing => bail!(
"{name} has no environment named \"{environment}\" (existing: {existing}).\n\
A deploy would create it. If that is intended, pass --create-environment; \
if you meant another application, run from its checkout.",
name = target.application_name,
existing = if target.existing_environments.is_empty() {
"none".to_string()
} else {
target.existing_environments.join(", ")
}
),
Gate::AskToCreateEnvironment => {
log_warn!(
stdout,
"{} has no environment named \"{}\" (existing: {}).",
target.application_name,
environment,
if target.existing_environments.is_empty() {
"none".to_string()
} else {
target.existing_environments.join(", ")
}
);
let yes = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Create environment \"{}\" on {} and deploy to it?",
environment, target.application_name
))
.default(false)
.interact()?;
if !yes {
bail!("Deploy cancelled: environment \"{environment}\" was not created.");
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(repo: Option<&str>, exists: Option<bool>) -> DeployTarget {
DeployTarget {
application_name: "main-street".into(),
repository: repo.map(str::to_string),
environment_exists: exists,
existing_environments: vec!["demo".into()],
}
}
#[test]
fn repository_spellings_compare_equal() {
let https = normalize_repository("https://github.com/Main-Street-App/mainstreet-hq");
for other in [
"git@github.com:Main-Street-App/mainstreet-hq.git",
"ssh://git@github.com/main-street-app/mainstreet-hq.git",
"https://github.com/Main-Street-App/mainstreet-hq.git/",
"http://github.com/Main-Street-App/mainstreet-hq",
] {
assert_eq!(normalize_repository(other), https, "{other}");
}
assert_ne!(
normalize_repository("git@github.com:Main-Street-App/mainstreet-locale.git"),
https
);
}
#[test]
fn the_wrong_checkout_is_refused_before_anything_else() {
let t = target(
Some("https://github.com/Main-Street-App/mainstreet-hq"),
Some(false),
);
let g = gate(
&t,
Some("git@github.com:Main-Street-App/mainstreet-locale.git"),
false,
false,
true,
);
assert!(matches!(g, Gate::RepositoryMismatch { .. }));
assert_eq!(
gate(
&t,
Some("git@github.com:Main-Street-App/mainstreet-locale.git"),
false,
true,
true
),
Gate::Proceed
);
}
#[test]
fn a_missing_environment_needs_a_flag_or_a_person() {
let t = target(Some("https://github.com/o/r"), Some(false));
assert_eq!(
gate(&t, Some("https://github.com/o/r"), false, false, false),
Gate::EnvironmentMissing
);
assert_eq!(
gate(&t, Some("https://github.com/o/r"), false, false, true),
Gate::AskToCreateEnvironment
);
assert_eq!(
gate(&t, Some("https://github.com/o/r"), true, false, false),
Gate::Proceed
);
}
#[test]
fn unknowns_never_block() {
assert_eq!(
gate(
&target(Some("https://github.com/o/r"), Some(true)),
None,
false,
false,
false
),
Gate::Proceed
);
assert_eq!(
gate(
&target(None, Some(true)),
Some("https://github.com/o/r"),
false,
false,
false
),
Gate::Proceed
);
assert_eq!(
gate(
&target(Some("https://github.com/o/r"), None),
Some("https://github.com/o/r"),
false,
false,
false
),
Gate::Proceed
);
}
}