mod api;
mod commands;
mod config;
mod render;
mod update;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use owo_colors::OwoColorize;
use api::client::ApiClient;
use config::Config;
use render::theme;
#[derive(Parser)]
#[command(
name = "fundaia",
version,
about = "Proyectos, servicios, variables, despliegues, registros y métricas",
long_about = None,
propagate_version = true
)]
struct Cli {
#[arg(long, short, global = true, env = "FUNDAIA_PROFILE")]
profile: Option<String>,
#[arg(long, global = true, env = "FUNDAIA_URL")]
url: Option<String>,
#[arg(long, global = true, env = "FUNDAIA_TOKEN", hide_env_values = true)]
token: Option<String>,
#[arg(long, global = true)]
no_color: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Login {
#[arg(long, default_value = "https://infrastructure.fundaia.com")]
url: String,
#[arg(long, default_value = "default")]
as_profile: String,
#[arg(long)]
no_browser: bool,
},
Logout {
#[arg(long)]
profile: Option<String>,
},
Upgrade {
#[arg(long)]
check: bool,
},
Whoami,
Status,
Projects,
Project {
#[command(subcommand)]
command: ProjectCommand,
},
Services { project: String },
Service { project: String, service: String },
New {
project: String,
name: String,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
image: Option<String>,
#[arg(long)]
template: Option<String>,
#[arg(long, default_value = "main")]
branch: String,
#[arg(long = "connect-to", value_delimiter = ',')]
connect_to: Vec<String>,
},
Templates,
Deploy {
project: String,
service: String,
#[arg(long)]
restart: bool,
#[arg(long)]
detach: bool,
},
Rollback {
project: String,
service: String,
number: u32,
},
Stop { project: String, service: String },
History {
project: String,
service: String,
#[arg(long, default_value_t = 15)]
limit: usize,
},
Logs {
project: String,
service: String,
#[arg(long, short)]
follow: bool,
#[arg(long, short = 'n', default_value_t = 200)]
tail: usize,
#[arg(long)]
stream: Option<String>,
#[arg(long)]
deployment: Option<u32>,
#[arg(long, short = 't')]
timestamps: bool,
},
Metrics {
project: String,
service: String,
#[arg(long, default_value_t = 60)]
minutes: u32,
},
#[command(alias = "vars")]
Variables {
#[command(subcommand)]
command: VariableCommand,
},
Domains {
#[command(subcommand)]
command: DomainCommand,
},
Connect {
project: String,
service: String,
provider: String,
},
Disconnect {
project: String,
service: String,
provider: String,
},
Connectable { project: String, service: String },
Volumes {
#[command(subcommand)]
command: VolumeCommand,
},
Recipe {
#[command(subcommand)]
command: RecipeCommand,
},
#[command(alias = "workspaces")]
Workspace {
#[command(subcommand)]
command: WorkspaceCommand,
},
Capacity,
Settings {
project: String,
service: String,
#[arg(long)]
memory: Option<u32>,
#[arg(long)]
replicas: Option<u32>,
#[arg(long)]
root: Option<String>,
#[arg(long = "start-command")]
start_command: Option<String>,
#[arg(long = "health-path")]
health_path: Option<String>,
#[arg(long = "restart")]
restart_policy: Option<String>,
#[arg(long)]
sleep: Option<bool>,
#[arg(long = "auto-deploy")]
auto_deploy: Option<bool>,
#[arg(long = "cache")]
cache_enabled: Option<bool>,
#[arg(long = "cache-ttl")]
cache_ttl_seconds: Option<u32>,
},
Expose {
project: String,
service: String,
#[arg(long)]
off: bool,
},
Encrypt {
project: String,
service: String,
#[arg(long)]
off: bool,
},
Delete {
project: String,
service: String,
#[arg(long)]
confirm: Option<String>,
},
}
#[derive(Subcommand)]
enum WorkspaceCommand {
List,
#[command(alias = "who")]
Members { workspace: Option<String> },
Invite {
email: String,
#[arg(long)]
workspace: Option<String>,
#[arg(long)]
admin: bool,
},
Revoke {
invitation_id: String,
#[arg(long)]
workspace: Option<String>,
},
Remove {
user_id: String,
#[arg(long)]
workspace: Option<String>,
},
}
#[derive(Subcommand)]
enum ProjectCommand {
New {
name: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
workspace: Option<String>,
},
Rename {
project: String,
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
Archive {
project: String,
#[arg(long)]
confirm: Option<String>,
},
Move { project: String, workspace: String },
}
#[derive(Subcommand)]
enum VolumeCommand {
List {
project: String,
service: String,
},
Add {
project: String,
service: String,
mount_path: String,
},
Remove {
volume_id: String,
},
}
#[derive(Subcommand)]
enum RecipeCommand {
Show {
project: String,
service: String,
#[arg(long)]
plain: bool,
},
Edit {
project: String,
service: String,
#[arg(long, default_value = "-")]
file: String,
},
}
#[derive(Subcommand)]
enum VariableCommand {
List {
project: String,
service: String,
#[arg(long)]
reveal: bool,
},
Set {
project: String,
service: String,
key: String,
value: String,
#[arg(long)]
secret: bool,
#[arg(long)]
sealed: bool,
},
Seal {
project: String,
service: String,
key: String,
#[arg(long)]
confirm: Option<String>,
},
Unset {
project: String,
service: String,
key: String,
},
Import {
project: String,
service: String,
file: String,
#[arg(long)]
secret: bool,
},
}
#[derive(Subcommand)]
enum DomainCommand {
List {
project: String,
service: String,
},
Add {
project: String,
service: String,
host: Option<String>,
},
Remove {
domain_id: String,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if cli.no_color {
owo_colors::set_override(false);
}
let update = update::UpdateCheck::start(env!("CARGO_PKG_VERSION"));
let outcome = run(cli).await;
update.finish().await;
if let Err(error) = outcome {
eprintln!();
eprintln!(" {} {error}", theme::CROSS.style(theme::danger()));
for cause in error.chain().skip(1) {
eprintln!(" {}", cause.style(theme::muted()));
}
if let Some(hint) = hint_for(&error) {
eprintln!(" {}", hint.style(theme::muted()));
}
eprintln!();
std::process::exit(1);
}
}
fn hint_for(error: &anyhow::Error) -> Option<&'static str> {
let api = error.downcast_ref::<api::client::ApiError>()?;
match api.code.as_str() {
"unauthorized" => Some("Ejecuta `fundaia login` para volver a entrar."),
"rate_limited" => Some("Demasiadas peticiones seguidas: espera un minuto."),
"conflict" => Some("Ya hay algo con ese nombre, o hay un despliegue en curso."),
_ => None,
}
}
async fn run(cli: Cli) -> Result<()> {
match &cli.command {
Command::Login {
url,
as_profile,
no_browser,
} => return commands::login::run(url, as_profile, !no_browser).await,
Command::Logout { profile } => return commands::login::logout(profile.as_deref()),
Command::Upgrade { check } => {
return update::upgrade(env!("CARGO_PKG_VERSION"), *check).await
}
_ => {}
}
let client = connect(&cli)?;
match cli.command {
Command::Login { .. } | Command::Logout { .. } | Command::Upgrade { .. } => {
unreachable!("handled above")
}
Command::Whoami => {
let identity = client.whoami().await?;
match identity {
Some(principal) => println!(
" {} en {}",
principal.display_name.style(theme::strong()),
client.base().style(theme::muted()),
),
None => println!(" No hay sesión."),
}
Ok(())
}
Command::Status => commands::read::status(&client).await,
Command::Projects => commands::read::projects(&client).await,
Command::Project { command } => match command {
ProjectCommand::New {
name,
description,
workspace,
} => {
commands::write::create_project(
&client,
&name,
description.as_deref(),
workspace.as_deref(),
)
.await
}
ProjectCommand::Rename {
project,
name,
description,
} => {
commands::manage::rename_project(
&client,
&project,
name.as_deref(),
description.as_deref(),
)
.await
}
ProjectCommand::Archive { project, confirm } => {
commands::manage::archive_project(&client, &project, confirm.as_deref()).await
}
ProjectCommand::Move { project, workspace } => {
commands::manage::move_project(&client, &project, &workspace).await
}
},
Command::Services { project } => commands::read::services(&client, &project).await,
Command::Service { project, service } => {
commands::read::service(&client, &project, &service).await
}
Command::Templates => commands::write::templates(&client).await,
Command::New {
project,
name,
repo,
image,
template,
branch,
connect_to,
} => {
commands::write::create_service(
&client,
commands::write::ServiceRequest {
project: &project,
name: &name,
repo: repo.as_deref(),
image: image.as_deref(),
template: template.as_deref(),
branch: &branch,
connect_to: &connect_to,
},
)
.await
}
Command::Deploy {
project,
service,
restart,
detach,
} => commands::deploy::run(&client, &project, &service, restart, !detach).await,
Command::Rollback {
project,
service,
number,
} => commands::deploy::rollback(&client, &project, &service, number).await,
Command::Stop { project, service } => {
commands::write::stop(&client, &project, &service).await
}
Command::History {
project,
service,
limit,
} => commands::read::history(&client, &project, &service, limit).await,
Command::Logs {
project,
service,
follow,
tail,
stream,
deployment,
timestamps,
} => {
commands::logs::run(
&client,
&project,
&service,
commands::logs::LogQuery {
follow,
tail,
stream_filter: stream.as_deref(),
number: deployment,
timestamps,
},
)
.await
}
Command::Metrics {
project,
service,
minutes,
} => commands::read::metrics(&client, &project, &service, minutes).await,
Command::Variables { command } => match command {
VariableCommand::List {
project,
service,
reveal,
} => commands::read::variables(&client, &project, &service, reveal).await,
VariableCommand::Set {
project,
service,
key,
value,
secret,
sealed,
} => {
commands::write::set_variable(
&client, &project, &service, &key, &value, secret, sealed,
)
.await
}
VariableCommand::Seal {
project,
service,
key,
confirm,
} => {
commands::write::seal_variable(
&client,
&project,
&service,
&key,
confirm.as_deref(),
)
.await
}
VariableCommand::Unset {
project,
service,
key,
} => commands::write::unset_variable(&client, &project, &service, &key).await,
VariableCommand::Import {
project,
service,
file,
secret,
} => {
commands::write::import_variables(&client, &project, &service, &file, secret).await
}
},
Command::Domains { command } => match command {
DomainCommand::List { project, service } => {
commands::read::domains(&client, &project, &service).await
}
DomainCommand::Add {
project,
service,
host,
} => commands::write::add_domain(&client, &project, &service, host.as_deref()).await,
DomainCommand::Remove { domain_id } => {
commands::write::remove_domain(&client, &domain_id).await
}
},
Command::Connect {
project,
service,
provider,
} => commands::write::connect(&client, &project, &service, &provider).await,
Command::Disconnect {
project,
service,
provider,
} => commands::manage::disconnect(&client, &project, &service, &provider).await,
Command::Connectable { project, service } => {
commands::write::candidates(&client, &project, &service).await
}
Command::Volumes { command } => match command {
VolumeCommand::List { project, service } => {
commands::read::volumes(&client, &project, &service).await
}
VolumeCommand::Add {
project,
service,
mount_path,
} => commands::write::attach_volume(&client, &project, &service, &mount_path).await,
VolumeCommand::Remove { volume_id } => {
commands::manage::detach_volume(&client, &volume_id).await
}
},
Command::Recipe { command } => match command {
RecipeCommand::Show {
project,
service,
plain,
} => commands::read::recipe(&client, &project, &service, plain).await,
RecipeCommand::Edit {
project,
service,
file,
} => commands::write::edit_recipe(&client, &project, &service, &file).await,
},
Command::Capacity => commands::manage::capacity(&client).await,
Command::Settings {
project,
service,
memory,
replicas,
root,
start_command,
health_path,
restart_policy,
sleep,
auto_deploy,
cache_enabled,
cache_ttl_seconds,
} => {
commands::manage::set_settings(
&client,
&project,
&service,
api::models::SettingsPatch {
memory_mb: memory,
replicas,
root_directory: root,
start_command,
health_path,
restart_policy,
sleep_enabled: sleep,
auto_deploy,
cache_enabled,
cache_ttl_seconds,
},
)
.await
}
Command::Expose {
project,
service,
off,
} => commands::manage::expose(&client, &project, &service, !off).await,
Command::Encrypt {
project,
service,
off,
} => commands::manage::encryption(&client, &project, &service, !off).await,
Command::Delete {
project,
service,
confirm,
} => commands::manage::delete(&client, &project, &service, confirm.as_deref()).await,
Command::Workspace { command } => match command {
WorkspaceCommand::List => commands::workspace::list(&client).await,
WorkspaceCommand::Members { workspace } => {
commands::workspace::members(&client, workspace.as_deref()).await
}
WorkspaceCommand::Invite {
email,
workspace,
admin,
} => commands::workspace::invite(&client, workspace.as_deref(), &email, admin).await,
WorkspaceCommand::Revoke {
invitation_id,
workspace,
} => commands::workspace::revoke(&client, workspace.as_deref(), &invitation_id).await,
WorkspaceCommand::Remove { user_id, workspace } => {
commands::workspace::remove(&client, workspace.as_deref(), &user_id).await
}
},
}
}
fn connect(cli: &Cli) -> Result<ApiClient> {
if let (Some(url), Some(token)) = (&cli.url, &cli.token) {
return ApiClient::new(url, Some(token.clone()));
}
let config = Config::load().context("could not read the configuration")?;
let (_, profile) = config.profile(cli.profile.as_deref())?;
ApiClient::new(
cli.url.as_deref().unwrap_or(&profile.url),
Some(cli.token.clone().unwrap_or_else(|| profile.token.clone())),
)
}