use std::time::Duration;
use anyhow::Result;
use owo_colors::OwoColorize;
use crate::api::client::{ApiClient, NewServer};
use crate::api::models::{
ConnectionTest, EnvironmentMove, RebalancePlan, Server, ServerCapacity, ServerMetricPoint,
};
use crate::commands::read::sparkline;
use crate::exit;
use crate::output::Format;
use crate::render::sanitise;
use crate::render::table::{Cell, Table};
use crate::render::theme;
use crate::render::time;
const MOVE_POLL: Duration = Duration::from_secs(2);
pub fn status_word(server: &Server) -> &'static str {
match server.status.as_str() {
"ready" if server.draining => "drenándose",
"ready" if !server.schedulable => "acordonado",
"ready" => "listo",
"unreachable" => "no responde",
_ => "pendiente",
}
}
fn status_style(server: &Server) -> owo_colors::Style {
match server.status.as_str() {
"ready" if server.draining || !server.schedulable => theme::warning(),
"ready" => theme::success(),
"unreachable" => theme::danger(),
_ => theme::muted(),
}
}
pub fn memory_line(server: &Server) -> String {
server.capacity.as_ref().map_or_else(
|| "—".to_owned(),
|capacity| {
format!(
"{} % de {:.1} GB",
capacity.memory_used_percent,
capacity.memory_total_mb as f64 / 1024.0
)
},
)
}
pub async fn list(client: &ApiClient, format: Format) -> Result<()> {
let answer = client.servers().await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&[
"servidor",
"estado",
"dirección",
"memoria",
"cpus",
"entornos",
"contenedores",
]);
for server in &answer.body.servers {
let name = if server.primary {
format!("{} (principal)", sanitise::plain(&server.name))
} else {
sanitise::plain(&server.name)
};
table.push(vec![
Cell::styled(name, theme::strong()),
Cell::styled(status_word(server), status_style(server)),
Cell::styled(sanitise::plain(&server.address), theme::muted()),
Cell::plain(memory_line(server)),
Cell::plain(
server
.capacity
.as_ref()
.map_or_else(|| "—".to_owned(), |capacity| capacity.cpus.to_string()),
),
Cell::plain(server.environments.unwrap_or_default().to_string()),
Cell::plain(server.containers.unwrap_or_default().to_string()),
]);
}
table.print();
let running: Vec<&EnvironmentMove> = answer
.body
.moves
.iter()
.filter(|movement| movement.status == "running")
.collect();
if !running.is_empty() {
println!();
for movement in running {
println!(" {}", describe_move(movement));
}
}
Ok(())
}
pub async fn show(client: &ApiClient, server: &str, format: Format) -> Result<()> {
let answer = client.server(server).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let detail = &answer.body;
let server = &detail.server;
println!(
" {} {} {}",
theme::state_dot(if server.status == "ready" {
"active"
} else {
"crashed"
}),
sanitise::plain(&server.name).style(theme::strong()),
status_word(server).style(status_style(server))
);
if let Some(capacity) = &server.capacity {
println!(
" {}",
format!(
"{} · podman {} · {} CPUs · memoria {} · {} contenedores",
sanitise::plain(&capacity.hostname),
sanitise::plain(&capacity.podman_version),
capacity.cpus,
memory_line(server),
capacity.containers_running
)
.style(theme::muted())
);
}
if let Some(error) = &server.last_error {
println!(" {}", sanitise::plain(error).style(theme::danger()));
}
if !server.labels.is_empty() {
println!(" etiquetas: {}", labels_line(&server.labels));
}
println!();
let mut table = Table::new(&["proyecto", "entorno", "servicios"]);
for placed in &detail.environments {
table.push(vec![
Cell::styled(sanitise::plain(&placed.project), theme::strong()),
Cell::plain(sanitise::plain(&placed.environment)),
Cell::plain(placed.services.to_string()),
]);
}
table.print_or("No tiene entornos.");
Ok(())
}
pub struct ServerRequest<'a> {
pub name: &'a str,
pub connection: &'a str,
pub address: &'a str,
pub identity: Option<&'a str>,
pub region: Option<&'a str>,
pub labels: &'a [String],
}
pub async fn add(client: &ApiClient, request: ServerRequest<'_>, format: Format) -> Result<()> {
let answer = client
.add_server(&NewServer {
name: request.name,
connection: request.connection,
address: request.address,
identity: request.identity,
region: request.region,
labels: request.labels,
})
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let server = &answer.body.server;
println!(
" {} {} se ha unido: {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&server.name).style(theme::strong()),
memory_line(server)
);
println!(
" {}",
"Los entornos nuevos irán al servidor con más memoria libre.".style(theme::muted())
);
Ok(())
}
pub async fn cordon(
client: &ApiClient,
server: &str,
cordoned: bool,
format: Format,
) -> Result<()> {
let answer = client.cordon_server(server, cordoned).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let server = &answer.body.server;
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&server.name).style(theme::strong()),
if cordoned {
"ya no acepta entornos nuevos"
} else {
"vuelve a aceptar entornos nuevos"
}
);
Ok(())
}
pub async fn failover(client: &ApiClient, server: &str, on: bool, format: Format) -> Result<()> {
let answer = client.set_server_failover(server, on).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let server = &answer.body.server;
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&server.name).style(theme::strong()),
if on {
"mudará sus entornos sin datos a otro servidor si pasa cinco minutos sin responder"
} else {
"esperará a volver si deja de responder; nada se muda solo"
}
);
Ok(())
}
pub async fn drain(client: &ApiClient, server: &str, dry_run: bool, format: Format) -> Result<()> {
let answer = if dry_run {
client.plan_drain(server).await?
} else {
client.drain_server(server).await?
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let plan = &answer.body;
let mut table = Table::new(&["proyecto", "entorno", "destino"]);
for movement in &plan.moves {
table.push(vec![
Cell::styled(sanitise::plain(&movement.project_name), theme::strong()),
Cell::plain(sanitise::plain(&movement.environment_name)),
Cell::styled(
sanitise::plain(movement.to.as_deref().unwrap_or("—")),
theme::accent(),
),
]);
}
for staying in &plan.staying {
table.push(vec![
Cell::styled(sanitise::plain(&staying.project_name), theme::strong()),
Cell::plain(sanitise::plain(&staying.environment_name)),
Cell::styled(
format!(
"se queda: {}",
sanitise::plain(staying.reason.as_deref().unwrap_or(""))
),
theme::warning(),
),
]);
}
table.print_or(format_args!(
"{} no tiene entornos que mover.",
sanitise::plain(&plan.server.name)
));
if !dry_run && !plan.moves.is_empty() {
println!();
println!(
" {}",
format!(
"Se mueven de uno en uno; sigue el progreso con fundaia servers list. {} ya no acepta entornos nuevos.",
sanitise::plain(&plan.server.name)
)
.style(theme::muted())
);
}
Ok(())
}
pub async fn remove(
client: &ApiClient,
server: &str,
confirmation: Option<&str>,
format: Format,
) -> Result<()> {
let detail = client.server(server).await?.body;
let name = detail.server.name.clone();
if confirmation != Some(name.as_str()) {
return Err(exit::Refused::invalid(format!(
"quitar el servidor `{name}` lo olvida para siempre: repite su nombre con --confirm {name}"
))
.into());
}
let answer = client.remove_server(&detail.server.id, &name).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
println!(
" {} {} ya no forma parte de la plataforma",
theme::CHECK.style(theme::success()),
sanitise::plain(&name).style(theme::strong())
);
Ok(())
}
pub fn describe_move(movement: &EnvironmentMove) -> String {
let route = format!(
"{} {} {}",
sanitise::plain(&movement.from.name),
theme::ARROW,
sanitise::plain(&movement.to.name)
);
match movement.status.as_str() {
"succeeded" => format!(
"{} {route} · terminada {}",
theme::CHECK.style(theme::success()),
movement
.finished_at
.as_deref()
.map(time::when)
.unwrap_or_default()
),
"failed" => format!(
"{} {route} · {}",
theme::CROSS.style(theme::danger()),
sanitise::plain(movement.error.as_deref().unwrap_or("falló"))
),
_ => format!(
"{} {route} · {}",
theme::PENDING.style(theme::accent()),
sanitise::plain(&movement.step)
),
}
}
pub async fn environment_server(
client: &ApiClient,
project_reference: &str,
environment: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.environment_server(&project.id, environment).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let placement = &answer.body;
println!(
" {} vive en {}{}",
sanitise::plain(environment).style(theme::strong()),
sanitise::plain(&placement.server.name).style(theme::accent()),
if placement.server.primary {
" (principal)"
} else {
""
}
);
if let Some(movement) = &placement.movement {
println!(" {}", describe_move(movement));
}
for reason in &placement.stays_on_primary {
println!(" {}", sanitise::plain(reason).style(theme::muted()));
}
if !placement.requires.is_empty() {
println!(
" {}",
format!("pide {}", labels_line(&placement.requires)).style(theme::muted())
);
}
if !placement.missing.is_empty() {
println!(
" {}",
format!(
"{} no lleva {}",
sanitise::plain(&placement.server.name),
labels_line(&placement.missing)
)
.style(theme::warning())
);
}
Ok(())
}
pub async fn move_environment(
client: &ApiClient,
project_reference: &str,
environment: &str,
server: &str,
wait: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let mut answer = client
.move_environment(&project.id, environment, server)
.await?;
if wait {
while answer.body.movement.status == "running" {
tokio::time::sleep(MOVE_POLL).await;
answer = client.environment_move(&answer.body.movement.id).await?;
}
}
if format.answered_in_json(&answer.raw) {
return failed_move(&answer.body.movement);
}
println!(" {}", describe_move(&answer.body.movement));
if !wait && answer.body.movement.status == "running" {
println!(
" {}",
format!(
"Sigue en marcha; fundaia env server {} {} dice cómo va.",
project.slug, environment
)
.style(theme::muted())
);
}
failed_move(&answer.body.movement)
}
fn failed_move(movement: &EnvironmentMove) -> Result<()> {
if movement.status == "failed" {
return Err(exit::Refused::upstream(
movement
.error
.clone()
.unwrap_or_else(|| "la mudanza falló".to_owned()),
)
.into());
}
Ok(())
}
pub fn labels_line(labels: &[String]) -> String {
if labels.is_empty() {
return "ninguna".to_owned();
}
labels
.iter()
.map(|label| sanitise::plain(label))
.collect::<Vec<_>>()
.join(", ")
}
pub async fn label(
client: &ApiClient,
server: &str,
set: &[String],
remove: &[String],
format: Format,
) -> Result<()> {
if set.is_empty() && remove.is_empty() {
return Err(exit::Refused::invalid(
"di qué etiquetas poner (region=eu ssd) o qué claves quitar (--rm region)",
)
.into());
}
let answer = client.label_server(server, set, remove).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let server = &answer.body.server;
println!(
" {} {} lleva: {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&server.name).style(theme::strong()),
labels_line(&server.labels)
);
Ok(())
}
pub async fn require_labels(
client: &ApiClient,
project_reference: &str,
environment: &str,
labels: &[String],
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client
.require_environment_labels(&project.id, environment, labels)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let placement = &answer.body;
if placement.requires.is_empty() {
println!(
" {} {} ya no pide etiquetas: va a cualquier servidor",
theme::CHECK.style(theme::success()),
sanitise::plain(environment).style(theme::strong())
);
} else {
println!(
" {} {} pide {}",
theme::CHECK.style(theme::success()),
sanitise::plain(environment).style(theme::strong()),
labels_line(&placement.requires)
);
}
if !placement.missing.is_empty() {
println!(
" {}",
format!(
"Vive en {}, que no lleva {}: múdalo con fundaia env move {} {} --to <servidor>.",
sanitise::plain(&placement.server.name),
labels_line(&placement.missing),
project.slug,
sanitise::plain(environment)
)
.style(theme::warning())
);
}
Ok(())
}
pub async fn rebalance(
client: &ApiClient,
apply: bool,
threshold: Option<u32>,
format: Format,
) -> Result<()> {
let answer = if apply {
client.rebalance(threshold).await?
} else {
client.plan_rebalance(threshold).await?
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let plan = &answer.body;
println!(" {}", sanitise::plain(&plan.summary));
println!();
for line in pressure_lines(plan) {
println!(" {line}");
}
if plan.moves.is_empty() {
return Ok(());
}
println!();
let mut table = Table::new(&["proyecto", "entorno", "de", "a", "memoria"]);
for movement in &plan.moves {
table.push(vec![
Cell::styled(sanitise::plain(&movement.project), theme::strong()),
Cell::plain(sanitise::plain(&movement.environment)),
Cell::plain(sanitise::plain(&movement.from.name)),
Cell::styled(sanitise::plain(&movement.to.name), theme::accent()),
Cell::plain(format!("{} MB", movement.memory_mb)),
]);
}
table.print();
println!();
println!(
" {}",
if apply {
"Se mueven de uno en uno; sigue el progreso con fundaia servers list."
} else {
"Nada se ha movido: fundaia servers rebalance --apply empieza estas mudanzas, de una en una."
}
.style(theme::muted())
);
Ok(())
}
pub fn pressure_lines(plan: &RebalancePlan) -> Vec<String> {
plan.servers
.iter()
.map(|server| {
if server.before == server.after {
format!(
"{}: {} % de memoria",
sanitise::plain(&server.name),
server.before
)
} else {
format!(
"{}: {} % {} {} % de memoria",
sanitise::plain(&server.name),
server.before,
theme::ARROW,
server.after
)
}
})
.collect()
}
pub async fn metrics(client: &ApiClient, server: &str, range: &str, format: Format) -> Result<()> {
let answer = client.server_metrics(server, range).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let metrics = &answer.body;
println!(
" {} {}",
sanitise::plain(&metrics.server.name).style(theme::strong()),
format!("· últimas {}", range_words(&metrics.range)).style(theme::muted())
);
let Some(latest) = metrics.points.last() else {
println!(
" {}",
"Sin muestras todavía: se toma una en cada latido, cada 30 segundos."
.style(theme::muted())
);
return Ok(());
};
println!();
let memory: Vec<u64> = metrics.points.iter().map(memory_percent).collect();
let containers: Vec<u64> = metrics
.points
.iter()
.map(|point| point.containers_running)
.collect();
println!(
" {} {} {} %",
"memoria ".style(theme::muted()),
sparkline(&memory),
memory_percent(latest)
);
if metrics
.points
.iter()
.any(|point| point.cpu_percent.is_some())
{
let cpu: Vec<u64> = metrics
.points
.iter()
.map(|point| point.cpu_percent.unwrap_or(0.0).round() as u64)
.collect();
println!(
" {} {} {}",
"cpu ".style(theme::muted()),
sparkline(&cpu),
latest
.cpu_percent
.map_or_else(|| "—".to_owned(), |cpu| format!("{cpu:.1} %"))
);
}
if let Some(disk) = latest.disk_used_percent {
let used: Vec<u64> = metrics
.points
.iter()
.map(|point| point.disk_used_percent.unwrap_or(0))
.collect();
println!(
" {} {} {disk} %",
"disco ".style(theme::muted()),
sparkline(&used)
);
}
println!(
" {} {} {}",
"contenedores".style(theme::muted()),
sparkline(&containers),
latest.containers_running
);
Ok(())
}
pub fn memory_percent(point: &ServerMetricPoint) -> u64 {
point.memory_used_percent.unwrap_or_else(|| {
(point.memory_used_mb * 100)
.checked_div(point.memory_total_mb)
.unwrap_or(0)
})
}
fn range_words(range: &str) -> &'static str {
match range {
"24h" => "24 horas",
"7d" => "7 días",
_ => "hora",
}
}
pub async fn bootstrap(client: &ApiClient, user: &str, script: bool, format: Format) -> Result<()> {
let answer = client.server_bootstrap(user).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let bootstrap = &answer.body;
if script {
print!("{}", sanitise::with_colour(&bootstrap.script));
return Ok(());
}
println!(
" {}",
"En la máquina nueva, con podman instalado, ejecuta como root (o con sudo):"
.style(theme::strong())
);
println!();
println!("{}", sanitise::plain(&bootstrap.command));
println!();
println!(
" {}",
format!(
"Crea el usuario {}, deja podman.socket en marcha para él, autoriza la clave de la plataforma e imprime la conexión que pegar en fundaia servers add.",
sanitise::plain(&bootstrap.user)
)
.style(theme::muted())
);
println!(
" {}",
"Para leer el script antes: fundaia servers bootstrap --script".style(theme::muted())
);
println!();
println!(
" {}",
"Clave pública de la plataforma:".style(theme::strong())
);
println!("{}", sanitise::plain(&bootstrap.public_key));
Ok(())
}
pub fn capacity_line(capacity: &ServerCapacity) -> String {
format!(
"{} · podman {} · {} CPUs · {:.1} GB de memoria, {} % en uso",
sanitise::plain(&capacity.hostname),
sanitise::plain(&capacity.podman_version),
capacity.cpus,
capacity.memory_total_mb as f64 / 1024.0,
capacity.memory_used_percent
)
}
pub async fn test_connection(
client: &ApiClient,
connection: &str,
identity: Option<&str>,
format: Format,
) -> Result<()> {
let answer = client.test_server_connection(connection, identity).await?;
if format.answered_in_json(&answer.raw) {
return failed_test(&answer.body);
}
let tested = &answer.body;
if tested.ok {
println!(
" {} podman contesta en {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&tested.connection).style(theme::strong())
);
if let Some(capacity) = &tested.capacity {
println!(" {}", capacity_line(capacity).style(theme::muted()));
}
} else {
println!(
" {} {}",
theme::CROSS.style(theme::danger()),
sanitise::plain(tested.error.as_deref().unwrap_or("no contesta"))
.style(theme::danger())
);
if let Some(hint) = &tested.hint {
println!(" {}", sanitise::plain(hint).style(theme::muted()));
}
}
if let Some(identity) = &tested.identity {
println!(
" {}",
format!("clave: {}", sanitise::plain(identity)).style(theme::muted())
);
}
failed_test(tested)
}
fn failed_test(tested: &ConnectionTest) -> Result<()> {
if tested.ok {
return Ok(());
}
Err(exit::Refused::upstream(
tested
.error
.clone()
.unwrap_or_else(|| "podman no contesta en esa conexión".to_owned()),
)
.into())
}
#[cfg(test)]
mod tests {
use super::*;
fn server(status: &str, schedulable: bool, draining: bool) -> Server {
Server {
id: "node_1".to_owned(),
name: "madrid".to_owned(),
primary: false,
address: "10.0.0.5".to_owned(),
region: None,
labels: Vec::new(),
status: status.to_owned(),
schedulable,
draining,
connection: None,
capacity: None,
last_seen_at: None,
last_error: None,
failover: true,
environments: None,
containers: None,
}
}
#[test]
fn it_should_call_a_cordoned_server_cordoned() {
assert_eq!(status_word(&server("ready", false, false)), "acordonado");
}
#[test]
fn it_should_call_a_draining_server_draining() {
assert_eq!(status_word(&server("ready", false, true)), "drenándose");
}
#[test]
fn it_should_say_an_unreachable_server_does_not_answer() {
assert_eq!(
status_word(&server("unreachable", true, false)),
"no responde"
);
}
#[test]
fn it_should_show_a_dash_for_a_server_never_measured() {
assert_eq!(memory_line(&server("pending", true, false)), "—");
}
#[test]
fn it_should_say_a_server_without_labels_carries_none() {
assert_eq!(labels_line(&[]), "ninguna");
}
#[test]
fn it_should_list_a_servers_labels_with_commas() {
assert_eq!(
labels_line(&["region=eu".to_owned(), "ssd".to_owned()]),
"region=eu, ssd"
);
}
fn point(used: u64, total: u64) -> ServerMetricPoint {
ServerMetricPoint {
at: "2026-10-16T10:00:00.000Z".to_owned(),
memory_used_mb: used,
memory_total_mb: total,
memory_used_percent: None,
disk_used_percent: None,
containers_running: 0,
cpu_percent: None,
}
}
#[test]
fn it_should_work_out_the_memory_percent_a_point_left_out() {
assert_eq!(memory_percent(&point(4096, 16384)), 25);
}
#[test]
fn it_should_read_a_point_of_a_server_with_no_memory_as_empty() {
assert_eq!(memory_percent(&point(0, 0)), 0);
}
#[test]
fn it_should_show_where_a_rebalance_takes_each_server() {
let plan = RebalancePlan {
threshold: 15,
spread_before: Some(50),
spread_after: Some(10),
summary: String::new(),
servers: vec![crate::api::models::ServerPressure {
id: "local".to_owned(),
name: "principal".to_owned(),
before: 80,
after: 55,
}],
moves: Vec::new(),
};
assert_eq!(
pressure_lines(&plan),
vec![format!("principal: 80 % {} 55 % de memoria", theme::ARROW)]
);
}
#[test]
fn it_should_fail_a_connection_test_that_did_not_reach_podman() {
let tested = ConnectionTest {
ok: false,
connection: "ssh://deploy@10.0.0.5:22/run/user/1000/podman/podman.sock".to_owned(),
identity: None,
capacity: None,
error: Some("Permission denied (publickey)".to_owned()),
hint: None,
};
assert!(failed_test(&tested).is_err());
}
}