use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_HORIZONTAL_ONLY};
use miette::{Result, bail, miette};
use tracing::{debug, info, warn};
use bestool_tamanu::seedling::{App, Oi, Resource, Show, target};
const SOCKET_VOLUME: &str = "socket";
pub async fn app(oi: &Oi, named: Option<&str>) -> Result<App> {
let apps = oi.apps().await?;
Ok(target(&apps, named)?.clone())
}
pub(crate) fn match_resources<'a>(
resources: Vec<&'a Resource>,
names: &[String],
ignore_unmatched: bool,
) -> Result<Vec<&'a Resource>> {
if names.is_empty() {
return Ok(resources);
}
let unmatched: Vec<&str> = names
.iter()
.map(String::as_str)
.filter(|name| !resources.iter().any(|r| r.name.contains(name)))
.collect();
if !unmatched.is_empty() {
let available: Vec<&str> = resources.iter().map(|r| r.name.as_str()).collect();
if ignore_unmatched {
warn!(
unmatched = unmatched.join(", "),
"ignoring name(s) matching none of this app's resources"
);
} else {
bail!(
"no resource matches: {}; available names are: {}",
unmatched.join(", "),
available.join(", "),
);
}
}
Ok(resources
.into_iter()
.filter(|r| names.iter().any(|name| r.name.contains(name)))
.collect())
}
pub async fn start(app_info: &App, oi: &Oi, names: &[String], ignore_unmatched: bool) -> Result<()> {
let app = &app_info.name;
if app_info.status.eq_ignore_ascii_case("not_installed") {
bail!("the {app} app is not installed, so there is nothing to start; it is installed with `seedling-ctl apps install`");
}
if names.is_empty() {
if !app_info.has_stopped_resources {
info!(
app,
status = %app_info.status,
"nothing to start: no resources are stopped"
);
return Ok(());
}
info!(app, "starting through the Seedling daemon");
oi.unstop(app).await?;
} else {
let show = oi.show(app).await?;
let matched = match_resources(show.stoppable().collect(), names, ignore_unmatched)?;
for resource in matched {
info!(app, kind = %resource.kind, name = %resource.name, "starting");
oi.unstop_resource(app, &resource.kind, &resource.name)
.await?;
}
}
report(oi, app).await;
Ok(())
}
pub async fn stop(app_info: &App, oi: &Oi, names: &[String]) -> Result<()> {
let app = &app_info.name;
let show = oi.show(app).await?;
let mut stoppable = match_resources(show.stoppable().collect(), names, false)?;
if stoppable.is_empty() {
info!(app, "nothing to stop: the app has no stoppable resources");
return Ok(());
}
let order = |r: &&Resource| match r.kind.as_str() {
"ingress" => 0,
"deployment" => 1,
_ => 2,
};
stoppable.sort_by_key(order);
for resource in stoppable {
info!(app, kind = %resource.kind, name = %resource.name, "stopping");
oi.stop_resource(app, &resource.kind, &resource.name)
.await?;
}
report(oi, app).await;
Ok(())
}
pub async fn restart(
app_info: &App,
oi: &Oi,
names: &[String],
ignore_unmatched: bool,
) -> Result<()> {
let app = &app_info.name;
let show = oi.show(app).await?;
let deployments: Vec<String> =
match_resources(show.deployments().collect(), names, ignore_unmatched)?
.into_iter()
.map(|r| r.name.clone())
.collect();
if deployments.is_empty() {
info!(app, "nothing to restart: the app has no deployments");
return Ok(());
}
for deployment in &deployments {
info!(app, deployment, "rolling");
oi.restart(app, deployment).await?;
}
report(oi, app).await;
Ok(())
}
pub async fn database_url(app_info: &App, oi: &Oi, username: Option<&str>) -> Result<String> {
let show = oi.show(&app_info.name).await?;
let database = show.param("db-name").ok_or_else(|| {
miette!(
"the {} app declares no db-name parameter, so its database can't be identified",
app_info.name
)
})?;
let user = match username {
Some(user) => user,
None => show.param("db-user").ok_or_else(|| {
miette!(
"the {} app declares no db-user parameter, so its role can't be identified",
app_info.name
)
})?,
};
let exported = oi.exported_volumes().await?;
let socket = exported
.iter()
.find(|v| v.volume_name == SOCKET_VOLUME && v.host_path.is_dir())
.ok_or_else(|| {
miette!(
"no app exports a `{SOCKET_VOLUME}` volume, so there is no local socket to reach a database through; the postgres app provides one when it is installed"
)
})?;
let host = socket.host_path.to_string_lossy();
if let Some(bad) = host.chars().find(|c| "?#&= ".contains(*c) || c.is_whitespace()) {
bail!(
"the exported socket directory {} contains {bad:?}, which can't be carried in a connection URL",
socket.host_path.display()
);
}
debug!(%database, %user, path = %socket.host_path.display(), "reaching the database over Seedling's exported socket");
Ok(format!("postgresql://{user}@/{database}?host={host}"))
}
pub async fn status(app_info: &App, oi: &Oi, names: &[String], use_colours: bool) -> Result<()> {
let app = &app_info.name;
let show = oi.show(app).await?;
println!("{app}: {}", show.status);
if !show.faults.is_empty() {
println!("{} app-level fault(s)", show.faults.len());
}
let mut table = Table::new();
table
.load_preset(UTF8_HORIZONTAL_ONLY)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["Resource", "Kind", "Instances", "Faults"]);
for resource in match_resources(show.resources.iter().collect(), names, false)? {
let instances = if resource.instances.is_empty() {
Cell::new("-")
} else {
let rendered = resource
.instances
.iter()
.map(|i| format!("{} {}", i.display_name, i.lifecycle))
.collect::<Vec<_>>()
.join("\n");
let all_live = resource
.instances
.iter()
.all(|i| matches!(i.lifecycle.as_str(), "Running" | "Ready"));
let cell = Cell::new(rendered);
if !use_colours {
cell
} else if all_live {
cell.fg(Color::Green)
} else {
cell.fg(Color::Yellow)
}
};
let faults = if resource.faults.is_empty() {
Cell::new("")
} else {
let cell = Cell::new(resource.faults.len());
if use_colours { cell.fg(Color::Red) } else { cell }
};
table.add_row(vec![
Cell::new(&resource.name),
Cell::new(&resource.kind),
instances,
faults,
]);
}
println!("{table}");
Ok(())
}
async fn report(oi: &Oi, app: &str) {
match oi.show(app).await {
Ok(Show { status, .. }) => info!(app, %status, "app state"),
Err(err) => warn!(app, %err, "could not read the app state back"),
}
}