use std::time::{SystemTime, UNIX_EPOCH};
use boatramp_core::cert::CertStatus;
use boatramp_core::deploy::{DeployMeta, DeploymentList, GcReport, ScrubReport};
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("control-plane request: {0}")]
Http(#[from] reqwest::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("no deployment matching {0}")]
NoDeploymentMatching(String),
#[error("no previous deployment to roll back to")]
NoPreviousDeployment,
#[error("{checked} blob(s) checked: {mismatched} corrupted, {unreadable} unreadable")]
ScrubFailed {
checked: usize,
mismatched: usize,
unreadable: usize,
},
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct DeploymentsArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
#[arg(long, env = "BOATRAMP_SITE")]
site: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
}
pub async fn list(args: DeploymentsArgs, config: &ProjectConfig) -> Result<()> {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
let list = cp.fetch_deployments(&site).await?;
if list.deployments.is_empty() {
println!("no deployments for {site}");
return Ok(());
}
for entry in list.deployments.iter().take(args.limit) {
let marker = if list.current.as_deref() == Some(entry.id.as_str()) {
"*"
} else {
" "
};
let detail = entry.meta.as_ref().map(meta_summary).unwrap_or_default();
println!("{marker} {} {}{detail}", short(&entry.id), ago(entry.at));
}
Ok(())
}
fn meta_summary(meta: &DeployMeta) -> String {
let mut parts = Vec::new();
if let Some(message) = meta.message.as_deref().filter(|m| !m.is_empty()) {
parts.push(message.to_string());
}
if let Some(tag) = meta.tag.as_deref().filter(|t| !t.is_empty()) {
parts.push(format!("[{tag}]"));
}
let mut origin = String::new();
if let Some(branch) = &meta.branch {
origin.push_str(branch);
}
if let Some(source) = &meta.source {
let sha = &source[..source.len().min(7)];
if origin.is_empty() {
origin = sha.to_string();
} else {
origin = format!("{origin}@{sha}");
}
}
if !origin.is_empty() {
parts.push(format!("({origin})"));
}
if !meta.tags.is_empty() {
parts.push(
meta.tags
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" "),
);
}
if parts.is_empty() {
String::new()
} else {
format!(" — {}", parts.join(" "))
}
}
#[derive(Debug, clap::Args)]
pub struct RollbackArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
#[arg(long, env = "BOATRAMP_SITE")]
site: Option<String>,
#[arg(long)]
to: Option<String>,
}
pub async fn rollback(args: RollbackArgs, config: &ProjectConfig) -> Result<()> {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
let list = cp.fetch_deployments(&site).await?;
let target = match &args.to {
Some(wanted) => {
resolve_id(&list, wanted).ok_or_else(|| Error::NoDeploymentMatching(wanted.clone()))?
}
None => previous(&list).ok_or(Error::NoPreviousDeployment)?,
};
cp.activate(&site, &target).await?;
println!("rolled back {site} -> {target}");
Ok(())
}
#[derive(Debug, clap::Args)]
pub struct StatusArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
#[arg(long, env = "BOATRAMP_SITE")]
site: Option<String>,
}
pub async fn status(args: StatusArgs, config: &ProjectConfig) -> Result<()> {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
let list = cp.fetch_deployments(&site).await?;
let Some(current) = list.current.clone() else {
println!("{site}: no active deployment");
return Ok(());
};
let manifest = cp.fetch_manifest(&site, ¤t).await?;
let files = manifest.files.len();
let bytes: u64 = manifest.files.values().map(|entry| entry.size).sum();
let current_entry = list.deployments.iter().find(|entry| entry.id == current);
let age = current_entry.map(|entry| ago(entry.at));
let meta = current_entry.and_then(|entry| entry.meta.as_ref());
println!("{site}");
println!(" deployment {}", short(¤t));
println!(" activated {}", age.as_deref().unwrap_or("unknown"));
println!(" content {files} file(s), {}", human_bytes(bytes));
println!(" history {} deployment(s)", list.deployments.len());
if let Some(meta) = meta {
if let Some(message) = meta.message.as_deref().filter(|m| !m.is_empty()) {
println!(" message {message}");
}
match (&meta.branch, &meta.source) {
(Some(branch), Some(source)) => {
println!(" source {branch}@{}", &source[..source.len().min(12)]);
}
(None, Some(source)) => println!(" source {}", &source[..source.len().min(12)]),
(Some(branch), None) => println!(" source {branch}"),
(None, None) => {}
}
if let Some(author) = meta.author.as_deref().filter(|a| !a.is_empty()) {
println!(" author {author}");
}
if let Some(tag) = meta.tag.as_deref().filter(|t| !t.is_empty()) {
println!(" release {tag}");
}
if !meta.tags.is_empty() {
let kv = meta
.tags
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
println!(" tags {kv}");
}
}
Ok(())
}
#[derive(Debug, clap::Args)]
pub struct PruneArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
keep_last: Option<usize>,
#[arg(long)]
keep_age: Option<u64>,
#[arg(long)]
grace: Option<u64>,
}
impl PruneArgs {
fn query(&self) -> Vec<(&'static str, String)> {
let mut q = Vec::new();
if let Some(n) = self.keep_last {
q.push(("keep_last", n.to_string()));
}
if let Some(secs) = self.keep_age {
q.push(("keep_age", secs.to_string()));
}
if let Some(secs) = self.grace {
q.push(("grace", secs.to_string()));
}
q
}
}
pub async fn prune(args: PruneArgs, config: &ProjectConfig) -> Result<()> {
let server = client::resolve_server(args.server.clone(), config)?;
let http = client::http_client(client::token(config).as_deref());
let url = format!("{server}/api/prune");
let query = args.query();
let report: GcReport = http
.get(&url)
.query(&query)
.send()
.await?
.error_for_status()?
.json()
.await?;
if report.manifests_removed == 0 && report.blobs_removed == 0 {
println!("nothing to prune");
return Ok(());
}
print_report(&report, "reclaimable");
if args.dry_run {
println!("run `boatramp prune` to delete");
return Ok(());
}
if !args.yes {
let prompt = format!(
"delete {} manifest(s) and {} blob(s) ({})? [y/N] ",
report.manifests_removed,
report.blobs_removed,
human_bytes(report.bytes_reclaimed),
);
if !confirm(&prompt)? {
println!("aborted");
return Ok(());
}
}
let deleted: GcReport = http
.post(&url)
.query(&query)
.send()
.await?
.error_for_status()?
.json()
.await?;
print_report(&deleted, "removed");
Ok(())
}
fn print_report(report: &GcReport, verb: &str) {
println!(
"{} orphan manifest(s), {} blob(s) {verb} ({})",
report.manifests_removed,
report.blobs_removed,
human_bytes(report.bytes_reclaimed),
);
println!(
" ({} blob(s), {} manifest(s) total)",
report.blobs_total, report.manifests_total
);
}
#[derive(Debug, clap::Args)]
pub struct ScrubArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
}
pub async fn scrub(args: ScrubArgs, config: &ProjectConfig) -> Result<()> {
let server = client::resolve_server(args.server, config)?;
let http = client::http_client(client::token(config).as_deref());
let report: ScrubReport = http
.post(format!("{server}/api/scrub"))
.send()
.await?
.error_for_status()?
.json()
.await?;
if report.is_clean() {
println!("{} blob(s) verified, all intact", report.checked);
return Ok(());
}
for m in &report.mismatched {
println!(
"CORRUPT {} (expected {}, got {})",
m.key, m.expected, m.actual
);
}
for e in &report.errors {
println!("UNREADABLE {} ({})", e.key, e.error);
}
Err(Error::ScrubFailed {
checked: report.checked,
mismatched: report.mismatched.len(),
unreadable: report.errors.len(),
})
}
#[derive(Debug, clap::Args)]
pub struct CertStatusArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
}
pub async fn cert_status(args: CertStatusArgs, config: &ProjectConfig) -> Result<()> {
let server = client::resolve_server(args.server, config)?;
let http = client::http_client(client::token(config).as_deref());
let certs: Vec<CertStatus> = http
.get(format!("{server}/api/certs"))
.send()
.await?
.error_for_status()?
.json()
.await?;
if certs.is_empty() {
println!("no cluster-managed certificates");
return Ok(());
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for cert in certs {
let remaining = cert.not_after_unix.saturating_sub(now);
let days = remaining / 86_400;
let state = if cert.not_after_unix <= now {
"EXPIRED".to_string()
} else {
format!("{days}d left")
};
println!("{} ({state})", cert.domain);
}
Ok(())
}
fn confirm(prompt: &str) -> Result<bool> {
use std::io::Write;
eprint!("{prompt}");
std::io::stderr().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
}
fn previous(list: &DeploymentList) -> Option<String> {
let current = list.current.as_deref()?;
let pos = list.deployments.iter().position(|e| e.id == current)?;
list.deployments.get(pos + 1).map(|e| e.id.clone())
}
fn resolve_id(list: &DeploymentList, wanted: &str) -> Option<String> {
if list.deployments.iter().any(|e| e.id == wanted) {
return Some(wanted.to_string());
}
let mut matches = list.deployments.iter().filter(|e| e.id.starts_with(wanted));
if let Some(first) = matches.next() {
return matches.next().is_none().then(|| first.id.clone());
}
if wanted.len() == 64 && wanted.bytes().all(|b| b.is_ascii_hexdigit()) {
return Some(wanted.to_string());
}
None
}
fn short(id: &str) -> &str {
&id[..id.len().min(12)]
}
fn ago(at: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let secs = now.saturating_sub(at);
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3_600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3_600)
} else {
format!("{}d ago", secs / 86_400)
}
}
fn human_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut size = n as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{n} B")
} else {
format!("{size:.1} {}", UNITS[unit])
}
}