use anyhow::Result;
use owo_colors::OwoColorize;
use crate::api::client::{ApiClient, NewEnvironment};
use crate::api::models::{
DataCopyStep, Environment, PromotedImage, PromotionStep, ServiceCopy, VariableDifference,
};
use crate::exit;
use crate::output::Format;
use crate::render::sanitise;
use crate::render::table::{short_sha, Cell, Table};
use crate::render::theme;
use crate::render::time::until;
pub async fn list(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.environments(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&["entorno", "slug", "servicios", "caduca", ""]);
for environment in &answer.body.environments {
table.push(vec![
Cell::styled(environment.name.clone(), theme::strong()),
Cell::styled(environment.slug.clone(), theme::muted()),
Cell::plain(
environment
.service_count
.map_or_else(|| "—".to_owned(), |count| count.to_string()),
),
Cell::styled(
environment
.expires_at
.as_deref()
.map_or_else(|| "—".to_owned(), until),
theme::warning(),
),
Cell::styled(environment_marks(environment), theme::muted()),
]);
}
table.print_or(format_args!("`{}` no tiene entornos.", project.name));
Ok(())
}
fn environment_marks(environment: &Environment) -> String {
match (environment.is_default, environment.protected) {
(true, true) => "por defecto · protegido".to_owned(),
(true, false) => "por defecto".to_owned(),
(false, true) => "protegido".to_owned(),
(false, false) => String::new(),
}
}
pub struct EnvironmentRequest<'a> {
pub project: &'a str,
pub name: &'a str,
pub from: Option<&'a str>,
pub empty: bool,
pub no_deploy: bool,
pub ttl: Option<&'a str>,
}
pub async fn create(
client: &ApiClient,
request: EnvironmentRequest<'_>,
format: Format,
) -> Result<()> {
let project = client.find_project(request.project).await?;
let answer = client
.create_environment(
&project.id,
&NewEnvironment {
name: request.name,
copy_from: request.from,
empty: request.empty,
deploy: !request.no_deploy,
ttl: request.ttl,
},
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let created = &answer.body;
println!();
println!(
" {} {} creado en {}{}",
theme::CHECK.style(theme::success()),
sanitise::plain(&created.environment.name).style(theme::strong()),
sanitise::plain(&project.name).style(theme::strong()),
created
.copied_from
.as_ref()
.map_or_else(String::new, |source| format!(
", copia de {}",
sanitise::plain(&source.name)
)),
);
copies(&created.services);
if let Some(expires_at) = created.environment.expires_at.as_deref() {
println!(
" {}",
format!(
"Caduca {}: se borra solo, con todo lo que tenga.",
until(expires_at)
)
.style(theme::warning())
);
}
println!(
" {}",
"Los volúmenes empiezan vacíos: fundaia env copy-data los llena desde otro entorno."
.style(theme::muted())
);
println!(
" {}",
format!(
"fundaia --env {} services {}",
created.environment.slug, project.slug
)
.style(theme::muted())
);
println!();
Ok(())
}
pub async fn rename(
client: &ApiClient,
project_reference: &str,
environment: &str,
name: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client
.rename_environment(&project.id, environment, name)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&answer.body.environment.name).style(theme::strong()),
format!("(sigue siendo {})", answer.body.environment.slug).style(theme::muted()),
);
Ok(())
}
pub async fn remove(
client: &ApiClient,
project_reference: &str,
environment_reference: &str,
confirmation: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let environment = named(
client.environments(&project.id).await?.body.environments,
environment_reference,
)?;
if confirmation != Some(environment.name.as_str())
&& confirmation != Some(environment.slug.as_str())
{
return Err(exit::Refused::invalid(format!(
"eliminar el entorno `{}` borra sus servicios, sus volúmenes con lo que tengan dentro y sus copias: repite su nombre con --confirm {}",
environment.name, environment.slug,
))
.into());
}
client
.delete_environment(
&project.id,
&environment.id,
confirmation.unwrap_or_default(),
)
.await?;
if format.is_json() {
crate::output::emit(&serde_json::json!({ "deleted": environment.id }));
return Ok(());
}
println!(
" {} {} eliminado de {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&environment.name).style(theme::strong()),
sanitise::plain(&project.name).style(theme::strong()),
);
Ok(())
}
pub async fn expire(
client: &ApiClient,
project_reference: &str,
environment: &str,
ttl: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client
.set_environment_expiry(&project.id, environment, Some(ttl))
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let environment = &answer.body.environment;
match environment.expires_at.as_deref() {
Some(expires_at) => println!(
" {} {} caduca {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&environment.name).style(theme::strong()),
until(expires_at),
format!("({expires_at}): se borra solo, con todo lo que tenga").style(theme::muted()),
),
None => println!(
" {} {} ya no caduca",
theme::CHECK.style(theme::success()),
sanitise::plain(&environment.name).style(theme::strong()),
),
}
Ok(())
}
pub struct PromoteRequest<'a> {
pub project: &'a str,
pub from: &'a str,
pub to: &'a str,
pub services: &'a [String],
pub dry_run: bool,
}
pub async fn promote(
client: &ApiClient,
request: PromoteRequest<'_>,
format: Format,
) -> Result<()> {
let project = client.find_project(request.project).await?;
let answer = if request.dry_run {
client
.promotion_plan(&project.id, request.from, request.to, request.services)
.await?
} else {
client
.promote_environment(&project.id, request.from, request.to, request.services)
.await?
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let plan = &answer.body;
println!();
println!(
" {} {} {}{}",
sanitise::plain(&plan.from.name).style(theme::strong()),
theme::ARROW.style(theme::muted()),
sanitise::plain(&plan.to.name).style(theme::strong()),
if request.dry_run {
" (plan: no cambia nada)"
} else {
""
}
.style(theme::muted()),
);
println!();
let mut table = Table::new(&["servicio", "ahora", "pasa a", "", "motivo"]);
for step in &plan.services {
table.push(promotion_row(step));
}
table.print_or(" No hay servicios que promover.");
let started = plan
.services
.iter()
.filter(|step| step.status == "promoting")
.count();
println!();
if request.dry_run {
println!(
" {}",
format!(
"fundaia env promote {} {} {} lo ejecuta, sin construir nada.",
project.slug, plan.from.slug, plan.to.slug
)
.style(theme::muted())
);
} else if started > 0 {
println!(
" {}",
format!(
"fundaia --env {} status {} sigue los despliegues.",
plan.to.slug, project.slug
)
.style(theme::muted())
);
}
println!();
Ok(())
}
fn promotion_row(step: &PromotionStep) -> Vec<Cell> {
let (word, style) = promotion_word(&step.status);
vec![
Cell::styled(step.name.clone(), theme::strong()),
Cell::styled(image_label(step.current.as_ref()), theme::muted()),
Cell::plain(image_label(step.source.as_ref())),
Cell::styled(word, style),
Cell::styled(step.reason.clone().unwrap_or_default(), theme::muted()),
]
}
fn promotion_word(status: &str) -> (&'static str, owo_colors::Style) {
match status {
"promote" => ("cambia", theme::accent()),
"promoting" => ("desplegando", theme::success()),
"unchanged" => ("sin cambios", theme::muted()),
_ => ("se salta", theme::warning()),
}
}
fn image_label(image: Option<&PromotedImage>) -> String {
let Some(image) = image else {
return "—".to_owned();
};
if let Some(tag) = &image.git_tag {
return format!("#{} {tag}", image.number);
}
if let Some(sha) = &image.commit_sha {
return format!("#{} {}", image.number, short_sha(sha));
}
match &image.image_reference {
Some(reference) => format!("#{} {}", image.number, short_image(reference)),
None => format!("#{}", image.number),
}
}
fn short_image(reference: &str) -> &str {
["docker.io/library/", "docker.io/", "localhost/infra/"]
.iter()
.find_map(|prefix| reference.strip_prefix(prefix))
.unwrap_or(reference)
}
pub struct DataCopyRequest<'a> {
pub project: &'a str,
pub from: &'a str,
pub to: &'a str,
pub services: &'a [String],
pub confirmation: Option<&'a str>,
pub dry_run: bool,
}
pub async fn copy_data(
client: &ApiClient,
request: DataCopyRequest<'_>,
format: Format,
) -> Result<()> {
let project = client.find_project(request.project).await?;
let answer = if request.dry_run {
client
.data_copy_plan(&project.id, request.from, request.to, request.services)
.await?
} else {
let target = named(
client.environments(&project.id).await?.body.environments,
request.to,
)?;
let confirmation = request.confirmation.unwrap_or_default();
if !confirms_target(&target, confirmation) {
return Err(exit::Refused::invalid(format!(
"copiar datos a `{}` sustituye lo que tienen sus volúmenes (antes se hace una copia de seguridad): repite su nombre con --confirm {}",
target.name,
if target.is_default {
&target.name
} else {
&target.slug
},
))
.into());
}
client
.copy_environment_data(
&project.id,
request.from,
&target.id,
request.services,
confirmation,
)
.await?
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let plan = &answer.body;
println!();
println!(
" Datos de {} {} {}{}",
sanitise::plain(&plan.from.name).style(theme::strong()),
theme::ARROW.style(theme::muted()),
sanitise::plain(&plan.to.name).style(theme::strong()),
if request.dry_run {
" (plan: no cambia nada)"
} else {
""
}
.style(theme::muted()),
);
println!();
let mut table = Table::new(&["servicio", "volúmenes", "", "motivo"]);
for step in &plan.services {
table.push(data_copy_row(step));
}
table.print_or(" Ningún servicio tiene volúmenes: no hay datos que copiar.");
let safety: Vec<&str> = plan
.services
.iter()
.flat_map(|step| step.safety_backups.iter().map(String::as_str))
.collect();
if !safety.is_empty() {
println!();
println!(
" {}",
format!(
"Lo que había quedó en {}: fundaia backups restore lo devuelve.",
safety.join(", ")
)
.style(theme::muted())
);
}
println!();
Ok(())
}
fn data_copy_row(step: &DataCopyStep) -> Vec<Cell> {
let (word, style) = match step.status.as_str() {
"copy" => ("se sustituye", theme::warning()),
"copied" => ("copiado", theme::success()),
"failed" => ("falló", theme::danger()),
_ => ("se salta", theme::muted()),
};
vec![
Cell::styled(step.name.clone(), theme::strong()),
Cell::plain(
step.volumes
.iter()
.map(|volume| volume.mount_path.as_str())
.collect::<Vec<_>>()
.join(", "),
),
Cell::styled(word, style),
Cell::styled(step.reason.clone().unwrap_or_default(), theme::muted()),
]
}
fn confirms_target(target: &Environment, typed: &str) -> bool {
let given = typed.trim();
!given.is_empty()
&& (given == target.name.trim() || (!target.is_default && given == target.slug))
}
pub async fn diff(
client: &ApiClient,
project_reference: &str,
from: &str,
to: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.compare_environments(&project.id, from, to).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let comparison = &answer.body;
let (left, right) = (&comparison.from.name, &comparison.to.name);
println!();
println!(
" {} {} {}",
sanitise::plain(left).style(theme::strong()),
"↔".style(theme::muted()),
sanitise::plain(right).style(theme::strong()),
);
section("compartidas del entorno", &comparison.shared, left, right);
for service in &comparison.services {
let title = match (&service.from, &service.to) {
(None, _) => format!("{} (solo en {right})", service.name),
(_, None) => format!("{} (solo en {left})", service.name),
_ => service.name.clone(),
};
section(&title, &service.difference, left, right);
}
println!();
println!(
" {}",
"Los secretos no se muestran: se comparan por su huella.".style(theme::muted())
);
println!();
Ok(())
}
fn section(title: &str, difference: &VariableDifference, left: &str, right: &str) {
println!();
let rows = difference_rows(difference);
let same = if difference.same.is_empty() && rows.is_empty() {
"sin variables".to_owned()
} else {
format!("{} iguales", difference.same.len())
};
println!(
" {} {}",
sanitise::plain(title).style(theme::strong()),
same.style(theme::muted()),
);
if rows.is_empty() {
return;
}
let mut table = Table::new(&["", "variable", left, right]);
for row in rows {
table.push(row);
}
table.print();
}
fn difference_rows(difference: &VariableDifference) -> Vec<Vec<Cell>> {
let changed = difference.different.iter().map(|one| {
vec![
Cell::styled("~", theme::warning()),
Cell::plain(one.key.clone()),
side_cell(&one.from.kind, one.from.value.as_deref(), theme::warning()),
side_cell(&one.to.kind, one.to.value.as_deref(), theme::warning()),
]
});
let removed = difference.only_in_from.iter().map(|one| {
vec![
Cell::styled("-", theme::danger()),
Cell::plain(one.key.clone()),
side_cell(&one.kind, one.value.as_deref(), theme::danger()),
Cell::styled("—", theme::muted()),
]
});
let added = difference.only_in_to.iter().map(|one| {
vec![
Cell::styled("+", theme::success()),
Cell::plain(one.key.clone()),
Cell::styled("—", theme::muted()),
side_cell(&one.kind, one.value.as_deref(), theme::success()),
]
});
changed.chain(removed).chain(added).collect()
}
fn side_words(kind: &str, value: Option<&str>) -> String {
if kind == "secret" {
return "secreto".to_owned();
}
value.unwrap_or_default().to_owned()
}
fn side_cell(kind: &str, value: Option<&str>, style: owo_colors::Style) -> Cell {
let style = if kind == "secret" {
theme::muted()
} else {
style
};
Cell::styled(side_words(kind, value), style)
}
pub struct CopyRequest<'a> {
pub project: &'a str,
pub service: &'a str,
pub from: Option<&'a str>,
pub to: &'a str,
pub no_deploy: bool,
}
pub async fn copy(client: &ApiClient, request: CopyRequest<'_>, format: Format) -> Result<()> {
let source = match request.from {
Some(from) => client.with_environment(Some(from)),
None => client.clone(),
};
let (project, service) = source.locate(request.project, request.service).await?;
let answer = client
.copy_service_to_environment(&project.id, request.to, &service.id, !request.no_deploy)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
println!();
copies(std::slice::from_ref(&answer.body.service));
println!();
Ok(())
}
pub fn copies(services: &[ServiceCopy]) {
for copy in services {
let (mark, word) = match copy.status.as_str() {
"deploying" => (theme::CHECK.style(theme::success()), "desplegando"),
"copied" => (theme::PENDING.style(theme::muted()), "copiado"),
_ => (theme::CROSS.style(theme::danger()), "no copiado"),
};
println!(
" {mark} {} {}",
sanitise::plain(©.name).style(theme::strong()),
word.style(theme::muted()),
);
if let Some(host) = ©.host {
println!(
" {} {}",
theme::ARROW.style(theme::muted()),
sanitise::plain(host)
);
}
if let Some(reason) = ©.reason {
println!(" {}", sanitise::plain(reason).style(theme::muted()));
}
for note in ©.notes {
println!(" {}", sanitise::plain(note).style(theme::muted()));
}
}
}
fn named(environments: Vec<Environment>, reference: &str) -> Result<Environment> {
let needle = reference.trim().to_lowercase();
environments
.into_iter()
.find(|one| one.id == reference || one.slug == needle || one.name.to_lowercase() == needle)
.ok_or_else(|| {
exit::Refused::not_found(format!("there is no environment called `{reference}`")).into()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn staging() -> Environment {
Environment {
id: "env_1".to_owned(),
name: "Staging".to_owned(),
slug: "staging".to_owned(),
is_default: false,
service_count: Some(2),
expires_at: None,
protected: false,
}
}
use crate::api::models::{ComparedKey, ComparedSide, DifferentKey};
fn image(over: impl FnOnce(&mut PromotedImage)) -> PromotedImage {
let mut image = PromotedImage {
deployment_id: "dep_7".to_owned(),
number: 7,
commit_sha: Some("c0ffee0123456789".to_owned()),
commit_subject: None,
git_tag: None,
image_reference: Some("localhost/infra/tienda/api--staging:7".to_owned()),
image_id: None,
};
over(&mut image);
image
}
#[test]
fn it_should_name_an_image_by_its_number_and_commit() {
assert_eq!(image_label(Some(&image(|_| {}))), "#7 c0ffee0");
}
#[test]
fn it_should_name_an_image_by_its_tag_when_it_has_one() {
let tagged = image(|one| one.git_tag = Some("v1.4.0".to_owned()));
assert_eq!(image_label(Some(&tagged)), "#7 v1.4.0");
}
#[test]
fn it_should_name_a_registry_image_without_its_registry() {
let pulled = image(|one| {
one.commit_sha = None;
one.image_reference = Some("docker.io/library/postgres:16".to_owned());
});
assert_eq!(image_label(Some(&pulled)), "#7 postgres:16");
}
#[test]
fn it_should_say_nothing_runs_yet() {
assert_eq!(image_label(None), "—");
}
#[test]
fn it_should_accept_the_slug_of_an_ordinary_environment() {
assert!(confirms_target(&staging(), "staging"));
}
#[test]
fn it_should_ask_for_the_exact_name_of_the_default_environment() {
let production = Environment {
name: "Producción".to_owned(),
slug: "production".to_owned(),
is_default: true,
..staging()
};
assert!(!confirms_target(&production, "production"));
}
#[test]
fn it_should_refuse_an_empty_confirmation() {
assert!(!confirms_target(&staging(), " "));
}
#[test]
fn it_should_never_print_a_secret() {
assert_eq!(side_words("secret", Some("sk_live_key")), "secreto");
}
#[test]
fn it_should_print_a_plain_value() {
assert_eq!(side_words("plain", Some("debug")), "debug");
}
#[test]
fn it_should_list_the_changed_keys_before_the_missing_ones() {
let difference = VariableDifference {
only_in_from: vec![ComparedKey {
key: "A".to_owned(),
kind: "plain".to_owned(),
value: Some("1".to_owned()),
}],
different: vec![DifferentKey {
key: "B".to_owned(),
from: ComparedSide {
kind: "plain".to_owned(),
value: Some("1".to_owned()),
},
to: ComparedSide {
kind: "plain".to_owned(),
value: Some("2".to_owned()),
},
}],
..VariableDifference::default()
};
assert_eq!(difference_rows(&difference).len(), 2);
}
#[test]
fn it_should_call_a_promotion_still_to_run_a_change() {
assert_eq!(promotion_word("promote").0, "cambia");
}
#[test]
fn it_should_call_an_unknown_status_a_skip() {
assert_eq!(promotion_word("skipped").0, "se salta");
}
#[test]
fn it_should_find_an_environment_by_its_name_in_any_case() {
assert_eq!(named(vec![staging()], "STAGING").unwrap().id, "env_1");
}
#[test]
fn it_should_find_an_environment_by_its_id() {
assert_eq!(named(vec![staging()], "env_1").unwrap().slug, "staging");
}
#[test]
fn it_should_refuse_an_environment_that_is_not_there() {
assert!(named(vec![staging()], "qa").is_err());
}
#[test]
fn it_should_mark_a_protected_environment_in_the_list() {
let production = Environment {
protected: true,
..staging()
};
assert_eq!(environment_marks(&production), "protegido");
}
}