use std::time::{SystemTime, UNIX_EPOCH};
use boatramp_core::cert::CertStatus;
use boatramp_core::deploy::{DeployMeta, DeploymentList, GcReport, HistoryEntry, 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, serde::Serialize)]
pub struct DeploymentsOutput {
site: String,
current: Option<String>,
total: usize,
deployments: Vec<HistoryEntry>,
}
impl crate::output::Rendered for DeploymentsOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
if self.total == 0 {
return writeln!(w, "no deployments for {}", self.site);
}
for entry in &self.deployments {
let marker = if self.current.as_deref() == Some(entry.id.as_str()) {
"*"
} else {
" "
};
let detail = entry.meta.as_ref().map(meta_summary).unwrap_or_default();
writeln!(
w,
"{marker} {} {}{detail}",
short(&entry.id),
ago(entry.at)
)?;
}
Ok(())
}
}
#[derive(Debug, serde::Serialize)]
pub struct RollbackOutput {
site: String,
target: String,
}
impl crate::output::Rendered for RollbackOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
writeln!(w, "rolled back {} -> {}", self.site, self.target)
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, serde::Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum StatusOutput {
NoDeployment { site: String },
Current {
site: String,
deployment: String,
activated: Option<String>,
files: usize,
bytes: u64,
history: usize,
#[serde(skip_serializing_if = "Option::is_none")]
meta: Option<DeployMeta>,
},
}
impl crate::output::Rendered for StatusOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::NoDeployment { site } => writeln!(w, "{site}: no active deployment"),
Self::Current {
site,
deployment,
activated,
files,
bytes,
history,
meta,
} => {
writeln!(w, "{site}")?;
writeln!(w, " deployment {}", short(deployment))?;
writeln!(
w,
" activated {}",
activated.as_deref().unwrap_or("unknown")
)?;
writeln!(w, " content {files} file(s), {}", human_bytes(*bytes))?;
writeln!(w, " history {history} deployment(s)")?;
if let Some(meta) = meta {
if let Some(message) = meta.message.as_deref().filter(|m| !m.is_empty()) {
writeln!(w, " message {message}")?;
}
match (&meta.branch, &meta.source) {
(Some(branch), Some(source)) => {
writeln!(
w,
" source {branch}@{}",
&source[..source.len().min(12)]
)?;
}
(None, Some(source)) => {
writeln!(w, " source {}", &source[..source.len().min(12)])?;
}
(Some(branch), None) => writeln!(w, " source {branch}")?,
(None, None) => {}
}
if let Some(author) = meta.author.as_deref().filter(|a| !a.is_empty()) {
writeln!(w, " author {author}")?;
}
if let Some(tag) = meta.tag.as_deref().filter(|t| !t.is_empty()) {
writeln!(w, " release {tag}")?;
}
if !meta.tags.is_empty() {
let kv = meta
.tags
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
writeln!(w, " tags {kv}")?;
}
}
Ok(())
}
}
}
}
#[derive(Debug, serde::Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum PruneOutput {
NothingToPrune,
DryRun { report: GcReport },
Aborted { report: GcReport },
Pruned { report: GcReport },
}
impl crate::output::Rendered for PruneOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::NothingToPrune => writeln!(w, "nothing to prune"),
Self::DryRun { .. } => writeln!(w, "run `boatramp prune` to delete"),
Self::Aborted { .. } => writeln!(w, "aborted"),
Self::Pruned { report } => write_gc_report(report, "removed", w),
}
}
}
#[derive(Debug, serde::Serialize)]
pub struct ScrubOutput {
checked: usize,
}
impl crate::output::Rendered for ScrubOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
writeln!(w, "{} blob(s) verified, all intact", self.checked)
}
}
#[derive(Debug, serde::Serialize)]
pub struct CertStatusOutput {
certs: Vec<CertStatus>,
}
impl crate::output::Rendered for CertStatusOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
if self.certs.is_empty() {
return writeln!(w, "no cluster-managed certificates");
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for cert in &self.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")
};
writeln!(w, "{} ({state})", cert.domain)?;
}
Ok(())
}
}
#[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<DeploymentsOutput> {
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 total = list.deployments.len();
let current = list.current;
let deployments = list.deployments.into_iter().take(args.limit).collect();
Ok(DeploymentsOutput {
site,
current,
total,
deployments,
})
}
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<RollbackOutput> {
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?;
Ok(RollbackOutput { site, target })
}
#[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<StatusOutput> {
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 {
return Ok(StatusOutput::NoDeployment { site });
};
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 activated = current_entry.map(|entry| ago(entry.at));
let meta = current_entry.and_then(|entry| entry.meta.clone());
let history = list.deployments.len();
Ok(StatusOutput::Current {
site,
deployment: current,
activated,
files,
bytes,
history,
meta,
})
}
#[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<PruneOutput> {
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 {
return Ok(PruneOutput::NothingToPrune);
}
{
use std::io::Write as _;
let mut out = std::io::stdout().lock();
write_gc_report(&report, "reclaimable", &mut out)?;
out.flush()?;
}
if args.dry_run {
return Ok(PruneOutput::DryRun { report });
}
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)? {
return Ok(PruneOutput::Aborted { report });
}
}
let deleted: GcReport = http
.post(&url)
.query(&query)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(PruneOutput::Pruned { report: deleted })
}
fn write_gc_report(
report: &GcReport,
verb: &str,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
writeln!(
w,
"{} orphan manifest(s), {} blob(s) {verb} ({})",
report.manifests_removed,
report.blobs_removed,
human_bytes(report.bytes_reclaimed),
)?;
writeln!(
w,
" ({} blob(s), {} manifest(s) total)",
report.blobs_total, report.manifests_total
)?;
Ok(())
}
#[derive(Debug, clap::Args)]
pub struct ScrubArgs {
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
}
pub async fn scrub(args: ScrubArgs, config: &ProjectConfig) -> Result<ScrubOutput> {
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() {
return Ok(ScrubOutput {
checked: report.checked,
});
}
{
use std::io::Write as _;
let mut out = std::io::stdout().lock();
for m in &report.mismatched {
writeln!(
out,
"CORRUPT {} (expected {}, got {})",
m.key, m.expected, m.actual
)?;
}
for e in &report.errors {
writeln!(out, "UNREADABLE {} ({})", e.key, e.error)?;
}
out.flush()?;
}
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<CertStatusOutput> {
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?;
Ok(CertStatusOutput { certs })
}
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])
}
}