refactor: migrate all modules from anyhow to SunbeamError

Replace anyhow::{bail, Context, Result} with crate::error::{Result,
SunbeamError, ResultExt} across all modules. Each module uses the
appropriate error variant (Kube, Secrets, Build, Identity, etc).
This commit is contained in:
2026-03-20 13:15:45 +00:00
parent cc0b6a833e
commit 7fd8874d99
12 changed files with 163 additions and 160 deletions

View File

@@ -1,6 +1,6 @@
//! Service-level health checks — functional probes beyond pod readiness. //! Service-level health checks — functional probes beyond pod readiness.
use anyhow::Result; use crate::error::Result;
use base64::Engine; use base64::Engine;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use k8s_openapi::api::core::v1::Pod; use k8s_openapi::api::core::v1::Pod;
@@ -87,7 +87,7 @@ async fn http_get(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
headers: Option<&[(&str, &str)]>, headers: Option<&[(&str, &str)]>,
) -> Result<(u16, Vec<u8>), String> { ) -> std::result::Result<(u16, Vec<u8>), String> {
let mut req = client.get(url); let mut req = client.get(url);
if let Some(hdrs) = headers { if let Some(hdrs) = headers {
for (k, v) in hdrs { for (k, v) in hdrs {

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Result}; use crate::error::{Result, SunbeamError};
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
/// Sunbeam local dev stack manager. /// Sunbeam local dev stack manager.
@@ -309,7 +309,7 @@ pub enum UserAction {
}, },
} }
fn validate_date(s: &str) -> Result<String, String> { fn validate_date(s: &str) -> std::result::Result<String, String> {
if s.is_empty() { if s.is_empty() {
return Ok(s.to_string()); return Ok(s.to_string());
} }
@@ -672,10 +672,10 @@ pub async fn dispatch() -> Result<()> {
Env::Production => { Env::Production => {
let host = crate::config::get_production_host(); let host = crate::config::get_production_host();
if host.is_empty() { if host.is_empty() {
bail!( return Err(SunbeamError::config(
"Production host not configured. \ "Production host not configured. \
Use `sunbeam config set --host` or set SUNBEAM_SSH_HOST." Use `sunbeam config set --host` or set SUNBEAM_SSH_HOST.",
); ));
} }
Some(host) Some(host)
} }

View File

@@ -2,7 +2,7 @@
//! //!
//! Pure K8s implementation: no Lima VM operations. //! Pure K8s implementation: no Lima VM operations.
use anyhow::{bail, Context, Result}; use crate::error::{Result, ResultExt, SunbeamError};
use std::path::PathBuf; use std::path::PathBuf;
const GITEA_ADMIN_USER: &str = "gitea_admin"; const GITEA_ADMIN_USER: &str = "gitea_admin";
@@ -36,10 +36,10 @@ async fn ensure_cert_manager() -> Result<()> {
// Download and apply cert-manager YAML // Download and apply cert-manager YAML
let body = reqwest::get(CERT_MANAGER_URL) let body = reqwest::get(CERT_MANAGER_URL)
.await .await
.context("Failed to download cert-manager manifest")? .ctx("Failed to download cert-manager manifest")?
.text() .text()
.await .await
.context("Failed to read cert-manager manifest body")?; .ctx("Failed to read cert-manager manifest body")?;
crate::kube::kube_apply(&body).await?; crate::kube::kube_apply(&body).await?;
@@ -73,7 +73,7 @@ async fn ensure_linkerd() -> Result<()> {
crate::output::ok("Installing Gateway API CRDs..."); crate::output::ok("Installing Gateway API CRDs...");
let gateway_body = reqwest::get(GATEWAY_API_CRDS_URL) let gateway_body = reqwest::get(GATEWAY_API_CRDS_URL)
.await .await
.context("Failed to download Gateway API CRDs")? .ctx("Failed to download Gateway API CRDs")?
.text() .text()
.await?; .await?;
@@ -86,11 +86,11 @@ async fn ensure_linkerd() -> Result<()> {
.args(["install", "--crds"]) .args(["install", "--crds"])
.output() .output()
.await .await
.context("Failed to run `linkerd install --crds`")?; .ctx("Failed to run `linkerd install --crds`")?;
if !crds_output.status.success() { if !crds_output.status.success() {
let stderr = String::from_utf8_lossy(&crds_output.stderr); let stderr = String::from_utf8_lossy(&crds_output.stderr);
bail!("linkerd install --crds failed: {stderr}"); return Err(SunbeamError::tool("linkerd", format!("install --crds failed: {stderr}")));
} }
let crds = String::from_utf8_lossy(&crds_output.stdout); let crds = String::from_utf8_lossy(&crds_output.stdout);
crate::kube::kube_apply(&crds).await?; crate::kube::kube_apply(&crds).await?;
@@ -101,11 +101,11 @@ async fn ensure_linkerd() -> Result<()> {
.args(["install"]) .args(["install"])
.output() .output()
.await .await
.context("Failed to run `linkerd install`")?; .ctx("Failed to run `linkerd install`")?;
if !cp_output.status.success() { if !cp_output.status.success() {
let stderr = String::from_utf8_lossy(&cp_output.stderr); let stderr = String::from_utf8_lossy(&cp_output.stderr);
bail!("linkerd install failed: {stderr}"); return Err(SunbeamError::tool("linkerd", format!("install failed: {stderr}")));
} }
let cp = String::from_utf8_lossy(&cp_output.stdout); let cp = String::from_utf8_lossy(&cp_output.stdout);
crate::kube::kube_apply(&cp).await?; crate::kube::kube_apply(&cp).await?;
@@ -141,24 +141,25 @@ async fn ensure_tls_cert(domain: &str) -> Result<()> {
crate::output::ok(&format!("Generating wildcard cert for *.{domain}...")); crate::output::ok(&format!("Generating wildcard cert for *.{domain}..."));
std::fs::create_dir_all(&dir) std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create secrets dir: {}", dir.display()))?; .with_ctx(|| format!("Failed to create secrets dir: {}", dir.display()))?;
let subject_alt_names = vec![format!("*.{domain}")]; let subject_alt_names = vec![format!("*.{domain}")];
let mut params = rcgen::CertificateParams::new(subject_alt_names) let mut params = rcgen::CertificateParams::new(subject_alt_names)
.context("Failed to create certificate params")?; .map_err(|e| SunbeamError::kube(format!("Failed to create certificate params: {e}")))?;
params params
.distinguished_name .distinguished_name
.push(rcgen::DnType::CommonName, format!("*.{domain}")); .push(rcgen::DnType::CommonName, format!("*.{domain}"));
let key_pair = rcgen::KeyPair::generate().context("Failed to generate key pair")?; let key_pair = rcgen::KeyPair::generate()
.map_err(|e| SunbeamError::kube(format!("Failed to generate key pair: {e}")))?;
let cert = params let cert = params
.self_signed(&key_pair) .self_signed(&key_pair)
.context("Failed to generate self-signed certificate")?; .map_err(|e| SunbeamError::kube(format!("Failed to generate self-signed certificate: {e}")))?;
std::fs::write(&cert_path, cert.pem()) std::fs::write(&cert_path, cert.pem())
.with_context(|| format!("Failed to write {}", cert_path.display()))?; .with_ctx(|| format!("Failed to write {}", cert_path.display()))?;
std::fs::write(&key_path, key_pair.serialize_pem()) std::fs::write(&key_path, key_pair.serialize_pem())
.with_context(|| format!("Failed to write {}", key_path.display()))?; .with_ctx(|| format!("Failed to write {}", key_path.display()))?;
crate::output::ok(&format!("Cert generated. Domain: {domain}")); crate::output::ok(&format!("Cert generated. Domain: {domain}"));
Ok(()) Ok(())
@@ -176,9 +177,9 @@ async fn ensure_tls_secret(domain: &str) -> Result<()> {
let dir = secrets_dir(); let dir = secrets_dir();
let cert_pem = let cert_pem =
std::fs::read_to_string(dir.join("tls.crt")).context("Failed to read tls.crt")?; std::fs::read_to_string(dir.join("tls.crt")).ctx("Failed to read tls.crt")?;
let key_pem = let key_pem =
std::fs::read_to_string(dir.join("tls.key")).context("Failed to read tls.key")?; std::fs::read_to_string(dir.join("tls.key")).ctx("Failed to read tls.key")?;
// Create TLS secret via kube-rs // Create TLS secret via kube-rs
let client = crate::kube::get_client().await?; let client = crate::kube::get_client().await?;
@@ -211,7 +212,7 @@ async fn ensure_tls_secret(domain: &str) -> Result<()> {
let pp = kube::api::PatchParams::apply("sunbeam").force(); let pp = kube::api::PatchParams::apply("sunbeam").force();
api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj)) api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj))
.await .await
.context("Failed to create TLS secret")?; .ctx("Failed to create TLS secret")?;
crate::output::ok("Done."); crate::output::ok("Done.");
Ok(()) Ok(())
@@ -289,7 +290,7 @@ async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) -> Result<(
loop { loop {
if Instant::now() > deadline { if Instant::now() > deadline {
bail!("Timed out waiting for deployment {ns}/{deployment}"); return Err(SunbeamError::kube(format!("Timed out waiting for deployment {ns}/{deployment}")));
} }
match api.get_opt(deployment).await? { match api.get_opt(deployment).await? {

View File

@@ -1,4 +1,4 @@
use anyhow::{Context, Result}; use crate::error::{Result, ResultExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
@@ -47,7 +47,7 @@ pub fn load_config() -> SunbeamConfig {
pub fn save_config(config: &SunbeamConfig) -> Result<()> { pub fn save_config(config: &SunbeamConfig) -> Result<()> {
let path = config_path(); let path = config_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| { std::fs::create_dir_all(parent).with_ctx(|| {
format!( format!(
"Failed to create config directory: {}", "Failed to create config directory: {}",
parent.display() parent.display()
@@ -56,7 +56,7 @@ pub fn save_config(config: &SunbeamConfig) -> Result<()> {
} }
let content = serde_json::to_string_pretty(config)?; let content = serde_json::to_string_pretty(config)?;
std::fs::write(&path, content) std::fs::write(&path, content)
.with_context(|| format!("Failed to save config to {}", path.display()))?; .with_ctx(|| format!("Failed to save config to {}", path.display()))?;
crate::output::ok(&format!("Configuration saved to {}", path.display())); crate::output::ok(&format!("Configuration saved to {}", path.display()));
Ok(()) Ok(())
} }
@@ -114,7 +114,7 @@ pub fn clear_config() -> Result<()> {
let path = config_path(); let path = config_path();
if path.exists() { if path.exists() {
std::fs::remove_file(&path) std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?; .with_ctx(|| format!("Failed to remove {}", path.display()))?;
crate::output::ok(&format!( crate::output::ok(&format!(
"Configuration cleared from {}", "Configuration cleared from {}",
path.display() path.display()

View File

@@ -1,6 +1,6 @@
//! Gitea bootstrap -- admin setup, org creation, OIDC auth source configuration. //! Gitea bootstrap -- admin setup, org creation, OIDC auth source configuration.
use anyhow::Result; use crate::error::Result;
use k8s_openapi::api::core::v1::Pod; use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ListParams}; use kube::api::{Api, ListParams};
use serde_json::Value; use serde_json::Value;

View File

@@ -1,6 +1,6 @@
//! Image building, mirroring, and pushing to Gitea registry. //! Image building, mirroring, and pushing to Gitea registry.
use anyhow::{bail, Context, Result}; use crate::error::{Result, ResultExt, SunbeamError};
use base64::Engine; use base64::Engine;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -86,7 +86,7 @@ async fn get_build_env() -> Result<BuildEnv> {
"password", "password",
) )
.await .await
.context("gitea-admin-credentials secret not found -- run seed first.")?; .ctx("gitea-admin-credentials secret not found -- run seed first.")?;
let platform = if is_prod { let platform = if is_prod {
"linux/amd64".to_string() "linux/amd64".to_string()
@@ -131,7 +131,7 @@ async fn buildctl_build_and_push(
) -> Result<()> { ) -> Result<()> {
// Find a free local port for port-forward // Find a free local port for port-forward
let listener = std::net::TcpListener::bind("127.0.0.1:0") let listener = std::net::TcpListener::bind("127.0.0.1:0")
.context("Failed to bind ephemeral port")?; .ctx("Failed to bind ephemeral port")?;
let local_port = listener.local_addr()?.port(); let local_port = listener.local_addr()?.port();
drop(listener); drop(listener);
@@ -144,10 +144,10 @@ async fn buildctl_build_and_push(
} }
}); });
let tmpdir = tempfile::TempDir::new().context("Failed to create temp dir")?; let tmpdir = tempfile::TempDir::new().ctx("Failed to create temp dir")?;
let cfg_path = tmpdir.path().join("config.json"); let cfg_path = tmpdir.path().join("config.json");
std::fs::write(&cfg_path, serde_json::to_string(&docker_cfg)?) std::fs::write(&cfg_path, serde_json::to_string(&docker_cfg)?)
.context("Failed to write docker config")?; .ctx("Failed to write docker config")?;
// Start port-forward to buildkitd // Start port-forward to buildkitd
let ctx_arg = format!("--context={}", crate::kube::context()); let ctx_arg = format!("--context={}", crate::kube::context());
@@ -165,14 +165,14 @@ async fn buildctl_build_and_push(
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn() .spawn()
.context("Failed to start buildkitd port-forward")?; .ctx("Failed to start buildkitd port-forward")?;
// Wait for port-forward to become ready // Wait for port-forward to become ready
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15);
loop { loop {
if tokio::time::Instant::now() > deadline { if tokio::time::Instant::now() > deadline {
pf.kill().await.ok(); pf.kill().await.ok();
bail!("buildkitd port-forward on :{local_port} did not become ready within 15s"); return Err(SunbeamError::tool("buildctl", format!("buildkitd port-forward on :{local_port} did not become ready within 15s")));
} }
if tokio::net::TcpStream::connect(format!("127.0.0.1:{local_port}")) if tokio::net::TcpStream::connect(format!("127.0.0.1:{local_port}"))
.await .await
@@ -247,8 +247,8 @@ async fn buildctl_build_and_push(
match result { match result {
Ok(status) if status.success() => Ok(()), Ok(status) if status.success() => Ok(()),
Ok(status) => bail!("buildctl exited with status {status}"), Ok(status) => return Err(SunbeamError::tool("buildctl", format!("exited with status {status}"))),
Err(e) => bail!("Failed to run buildctl: {e}"), Err(e) => return Err(SunbeamError::tool("buildctl", format!("failed to run: {e}"))),
} }
} }
@@ -320,7 +320,7 @@ async fn get_node_addresses() -> Result<Vec<String>> {
let node_list = api let node_list = api
.list(&kube::api::ListParams::default()) .list(&kube::api::ListParams::default())
.await .await
.context("Failed to list nodes")?; .ctx("Failed to list nodes")?;
let mut addresses = Vec::new(); let mut addresses = Vec::new();
for node in &node_list.items { for node in &node_list.items {
@@ -387,7 +387,7 @@ async fn ctr_pull_on_nodes(env: &BuildEnv, images: &[String]) -> Result<()> {
match status { match status {
Ok(s) if s.success() => ok(&format!("Pulled {img} on {node_ip}")), Ok(s) if s.success() => ok(&format!("Pulled {img} on {node_ip}")),
_ => bail!("ctr pull failed on {node_ip} for {img}"), _ => return Err(SunbeamError::tool("ctr", format!("pull failed on {node_ip} for {img}"))),
} }
} }
} }
@@ -440,7 +440,7 @@ async fn wait_deployment_ready(ns: &str, deployment: &str, timeout_secs: u64) ->
loop { loop {
if Instant::now() > deadline { if Instant::now() > deadline {
bail!("Timed out waiting for deployment {ns}/{deployment}"); return Err(SunbeamError::build(format!("Timed out waiting for deployment {ns}/{deployment}")));
} }
if let Some(dep) = api.get_opt(deployment).await? { if let Some(dep) = api.get_opt(deployment).await? {
@@ -477,10 +477,10 @@ async fn docker_hub_token(repo: &str) -> Result<String> {
); );
let resp: DockerAuthToken = reqwest::get(&url) let resp: DockerAuthToken = reqwest::get(&url)
.await .await
.context("Failed to fetch Docker Hub token")? .ctx("Failed to fetch Docker Hub token")?
.json() .json()
.await .await
.context("Failed to parse Docker Hub token response")?; .ctx("Failed to parse Docker Hub token response")?;
Ok(resp.token) Ok(resp.token)
} }
@@ -502,18 +502,18 @@ async fn fetch_manifest_index(
.header("Accept", accept) .header("Accept", accept)
.send() .send()
.await .await
.context("Failed to fetch manifest from Docker Hub")?; .ctx("Failed to fetch manifest from Docker Hub")?;
if !resp.status().is_success() { if !resp.status().is_success() {
bail!( return Err(SunbeamError::build(format!(
"Docker Hub returned {} for {repo}:{tag}", "Docker Hub returned {} for {repo}:{tag}",
resp.status() resp.status()
); )));
} }
resp.json() resp.json()
.await .await
.context("Failed to parse manifest index JSON") .ctx("Failed to parse manifest index JSON")
} }
/// Build an OCI tar archive containing a patched index that maps both /// Build an OCI tar archive containing a patched index that maps both
@@ -729,7 +729,7 @@ pub async fn cmd_mirror() -> Result<()> {
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.context("Failed to spawn ssh for ctr import")?; .ctx("Failed to spawn ssh for ctr import")?;
if let Some(mut stdin) = import_cmd.stdin.take() { if let Some(mut stdin) = import_cmd.stdin.take() {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -854,7 +854,7 @@ async fn build_proxy(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let proxy_dir = crate::config::get_repo_root().join("proxy"); let proxy_dir = crate::config::get_repo_root().join("proxy");
if !proxy_dir.is_dir() { if !proxy_dir.is_dir() {
bail!("Proxy source not found at {}", proxy_dir.display()); return Err(SunbeamError::build(format!("Proxy source not found at {}", proxy_dir.display())));
} }
let image = format!("{}/studio/proxy:latest", env.registry); let image = format!("{}/studio/proxy:latest", env.registry);
@@ -883,7 +883,7 @@ async fn build_tuwunel(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let tuwunel_dir = crate::config::get_repo_root().join("tuwunel"); let tuwunel_dir = crate::config::get_repo_root().join("tuwunel");
if !tuwunel_dir.is_dir() { if !tuwunel_dir.is_dir() {
bail!("Tuwunel source not found at {}", tuwunel_dir.display()); return Err(SunbeamError::build(format!("Tuwunel source not found at {}", tuwunel_dir.display())));
} }
let image = format!("{}/studio/tuwunel:latest", env.registry); let image = format!("{}/studio/tuwunel:latest", env.registry);
@@ -916,10 +916,10 @@ async fn build_integration(push: bool, deploy: bool) -> Result<()> {
let dockerignore = integration_service_dir.join(".dockerignore"); let dockerignore = integration_service_dir.join(".dockerignore");
if !dockerfile.exists() { if !dockerfile.exists() {
bail!( return Err(SunbeamError::build(format!(
"integration-service Dockerfile not found at {}", "integration-service Dockerfile not found at {}",
dockerfile.display() dockerfile.display()
); )));
} }
if !sunbeam_dir if !sunbeam_dir
.join("integration") .join("integration")
@@ -927,11 +927,11 @@ async fn build_integration(push: bool, deploy: bool) -> Result<()> {
.join("widgets") .join("widgets")
.is_dir() .is_dir()
{ {
bail!( return Err(SunbeamError::build(format!(
"integration repo not found at {} -- \ "integration repo not found at {} -- \
run: cd sunbeam && git clone https://github.com/suitenumerique/integration.git", run: cd sunbeam && git clone https://github.com/suitenumerique/integration.git",
sunbeam_dir.join("integration").display() sunbeam_dir.join("integration").display()
); )));
} }
let image = format!("{}/studio/integration:latest", env.registry); let image = format!("{}/studio/integration:latest", env.registry);
@@ -974,10 +974,10 @@ async fn build_kratos_admin(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let kratos_admin_dir = crate::config::get_repo_root().join("kratos-admin"); let kratos_admin_dir = crate::config::get_repo_root().join("kratos-admin");
if !kratos_admin_dir.is_dir() { if !kratos_admin_dir.is_dir() {
bail!( return Err(SunbeamError::build(format!(
"kratos-admin source not found at {}", "kratos-admin source not found at {}",
kratos_admin_dir.display() kratos_admin_dir.display()
); )));
} }
let image = format!("{}/studio/kratos-admin-ui:latest", env.registry); let image = format!("{}/studio/kratos-admin-ui:latest", env.registry);
@@ -1006,7 +1006,7 @@ async fn build_meet(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let meet_dir = crate::config::get_repo_root().join("meet"); let meet_dir = crate::config::get_repo_root().join("meet");
if !meet_dir.is_dir() { if !meet_dir.is_dir() {
bail!("meet source not found at {}", meet_dir.display()); return Err(SunbeamError::build(format!("meet source not found at {}", meet_dir.display())));
} }
let backend_image = format!("{}/studio/meet-backend:latest", env.registry); let backend_image = format!("{}/studio/meet-backend:latest", env.registry);
@@ -1031,10 +1031,10 @@ async fn build_meet(push: bool, deploy: bool) -> Result<()> {
step(&format!("Building meet-frontend -> {frontend_image} ...")); step(&format!("Building meet-frontend -> {frontend_image} ..."));
let frontend_dockerfile = meet_dir.join("src").join("frontend").join("Dockerfile"); let frontend_dockerfile = meet_dir.join("src").join("frontend").join("Dockerfile");
if !frontend_dockerfile.exists() { if !frontend_dockerfile.exists() {
bail!( return Err(SunbeamError::build(format!(
"meet frontend Dockerfile not found at {}", "meet frontend Dockerfile not found at {}",
frontend_dockerfile.display() frontend_dockerfile.display()
); )));
} }
let mut build_args = HashMap::new(); let mut build_args = HashMap::new();
@@ -1070,14 +1070,14 @@ async fn build_people(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let people_dir = crate::config::get_repo_root().join("people"); let people_dir = crate::config::get_repo_root().join("people");
if !people_dir.is_dir() { if !people_dir.is_dir() {
bail!("people source not found at {}", people_dir.display()); return Err(SunbeamError::build(format!("people source not found at {}", people_dir.display())));
} }
let workspace_dir = people_dir.join("src").join("frontend"); let workspace_dir = people_dir.join("src").join("frontend");
let app_dir = workspace_dir.join("apps").join("desk"); let app_dir = workspace_dir.join("apps").join("desk");
let dockerfile = workspace_dir.join("Dockerfile"); let dockerfile = workspace_dir.join("Dockerfile");
if !dockerfile.exists() { if !dockerfile.exists() {
bail!("Dockerfile not found at {}", dockerfile.display()); return Err(SunbeamError::build(format!("Dockerfile not found at {}", dockerfile.display())));
} }
let image = format!("{}/studio/people-frontend:latest", env.registry); let image = format!("{}/studio/people-frontend:latest", env.registry);
@@ -1090,9 +1090,9 @@ async fn build_people(push: bool, deploy: bool) -> Result<()> {
.current_dir(&workspace_dir) .current_dir(&workspace_dir)
.status() .status()
.await .await
.context("Failed to run yarn install")?; .ctx("Failed to run yarn install")?;
if !yarn_status.success() { if !yarn_status.success() {
bail!("yarn install failed"); return Err(SunbeamError::tool("yarn", "install failed"));
} }
// cunningham design tokens // cunningham design tokens
@@ -1106,9 +1106,9 @@ async fn build_people(push: bool, deploy: bool) -> Result<()> {
.current_dir(&app_dir) .current_dir(&app_dir)
.status() .status()
.await .await
.context("Failed to run cunningham")?; .ctx("Failed to run cunningham")?;
if !cunningham_status.success() { if !cunningham_status.success() {
bail!("cunningham failed"); return Err(SunbeamError::tool("cunningham", "design token generation failed"));
} }
let mut build_args = HashMap::new(); let mut build_args = HashMap::new();
@@ -1177,7 +1177,7 @@ async fn build_messages(what: &str, push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let messages_dir = crate::config::get_repo_root().join("messages"); let messages_dir = crate::config::get_repo_root().join("messages");
if !messages_dir.is_dir() { if !messages_dir.is_dir() {
bail!("messages source not found at {}", messages_dir.display()); return Err(SunbeamError::build(format!("messages source not found at {}", messages_dir.display())));
} }
let components: Vec<_> = if what == "messages" { let components: Vec<_> = if what == "messages" {
@@ -1278,10 +1278,10 @@ async fn build_la_suite_frontend(
let dockerfile = repo_dir.join(dockerfile_rel); let dockerfile = repo_dir.join(dockerfile_rel);
if !repo_dir.is_dir() { if !repo_dir.is_dir() {
bail!("{app} source not found at {}", repo_dir.display()); return Err(SunbeamError::build(format!("{app} source not found at {}", repo_dir.display())));
} }
if !dockerfile.exists() { if !dockerfile.exists() {
bail!("Dockerfile not found at {}", dockerfile.display()); return Err(SunbeamError::build(format!("Dockerfile not found at {}", dockerfile.display())));
} }
let image = format!("{}/studio/{image_name}:latest", env.registry); let image = format!("{}/studio/{image_name}:latest", env.registry);
@@ -1293,9 +1293,9 @@ async fn build_la_suite_frontend(
.current_dir(&workspace_dir) .current_dir(&workspace_dir)
.status() .status()
.await .await
.context("Failed to run yarn install")?; .ctx("Failed to run yarn install")?;
if !yarn_status.success() { if !yarn_status.success() {
bail!("yarn install failed"); return Err(SunbeamError::tool("yarn", "install failed"));
} }
ok("Regenerating cunningham design tokens (yarn build-theme)..."); ok("Regenerating cunningham design tokens (yarn build-theme)...");
@@ -1304,9 +1304,9 @@ async fn build_la_suite_frontend(
.current_dir(&app_dir) .current_dir(&app_dir)
.status() .status()
.await .await
.context("Failed to run yarn build-theme")?; .ctx("Failed to run yarn build-theme")?;
if !theme_status.success() { if !theme_status.success() {
bail!("yarn build-theme failed"); return Err(SunbeamError::tool("yarn", "build-theme failed"));
} }
let mut build_args = HashMap::new(); let mut build_args = HashMap::new();
@@ -1338,7 +1338,7 @@ async fn patch_dockerfile_uv(
platform: &str, platform: &str,
) -> Result<(PathBuf, Vec<PathBuf>)> { ) -> Result<(PathBuf, Vec<PathBuf>)> {
let content = std::fs::read_to_string(dockerfile_path) let content = std::fs::read_to_string(dockerfile_path)
.context("Failed to read Dockerfile for uv patching")?; .ctx("Failed to read Dockerfile for uv patching")?;
// Match COPY --from=ghcr.io/astral-sh/uv@sha256:... /uv /uvx /bin/ // Match COPY --from=ghcr.io/astral-sh/uv@sha256:... /uv /uvx /bin/
let original_copy = content let original_copy = content
@@ -1408,7 +1408,7 @@ async fn patch_dockerfile_uv(
// Download tarball // Download tarball
let response = reqwest::get(&url) let response = reqwest::get(&url)
.await .await
.context("Failed to download uv release")?; .ctx("Failed to download uv release")?;
let tarball_bytes = response.bytes().await?; let tarball_bytes = response.bytes().await?;
// Extract uv and uvx from tarball // Extract uv and uvx from tarball
@@ -1456,7 +1456,7 @@ async fn build_projects(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let projects_dir = crate::config::get_repo_root().join("projects"); let projects_dir = crate::config::get_repo_root().join("projects");
if !projects_dir.is_dir() { if !projects_dir.is_dir() {
bail!("projects source not found at {}", projects_dir.display()); return Err(SunbeamError::build(format!("projects source not found at {}", projects_dir.display())));
} }
let image = format!("{}/studio/projects:latest", env.registry); let image = format!("{}/studio/projects:latest", env.registry);
@@ -1485,7 +1485,7 @@ async fn build_calendars(push: bool, deploy: bool) -> Result<()> {
let env = get_build_env().await?; let env = get_build_env().await?;
let cal_dir = crate::config::get_repo_root().join("calendars"); let cal_dir = crate::config::get_repo_root().join("calendars");
if !cal_dir.is_dir() { if !cal_dir.is_dir() {
bail!("calendars source not found at {}", cal_dir.display()); return Err(SunbeamError::build(format!("calendars source not found at {}", cal_dir.display())));
} }
let backend_dir = cal_dir.join("src").join("backend"); let backend_dir = cal_dir.join("src").join("backend");

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Context, Result}; use crate::error::{Result, SunbeamError, ResultExt};
use base64::Engine; use base64::Engine;
use k8s_openapi::api::apps::v1::Deployment; use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::{Namespace, Secret}; use k8s_openapi::api::core::v1::{Namespace, Secret};
@@ -71,7 +71,7 @@ pub async fn ensure_tunnel() -> Result<()> {
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn() .spawn()
.context("Failed to spawn SSH tunnel")?; .ctx("Failed to spawn SSH tunnel")?;
// Wait for tunnel to become available // Wait for tunnel to become available
for _ in 0..20 { for _ in 0..20 {
@@ -98,15 +98,15 @@ pub async fn get_client() -> Result<&'static Client> {
.get_or_try_init(|| async { .get_or_try_init(|| async {
ensure_tunnel().await?; ensure_tunnel().await?;
let kubeconfig = Kubeconfig::read().context("Failed to read kubeconfig")?; let kubeconfig = Kubeconfig::read().map_err(|e| SunbeamError::kube(format!("Failed to read kubeconfig: {e}")))?;
let options = KubeConfigOptions { let options = KubeConfigOptions {
context: Some(context().to_string()), context: Some(context().to_string()),
..Default::default() ..Default::default()
}; };
let config = Config::from_custom_kubeconfig(kubeconfig, &options) let config = Config::from_custom_kubeconfig(kubeconfig, &options)
.await .await
.context("Failed to build kube config from kubeconfig")?; .map_err(|e| SunbeamError::kube(format!("Failed to build kube config from kubeconfig: {e}")))?;
Client::try_from(config).context("Failed to create kube client") Client::try_from(config).ctx("Failed to create kube client")
}) })
.await .await
} }
@@ -129,7 +129,7 @@ pub async fn kube_apply(manifest: &str) -> Result<()> {
// Parse the YAML to a DynamicObject so we can route it // Parse the YAML to a DynamicObject so we can route it
let obj: serde_yaml::Value = let obj: serde_yaml::Value =
serde_yaml::from_str(doc).context("Failed to parse YAML document")?; serde_yaml::from_str(doc).ctx("Failed to parse YAML document")?;
let api_version = obj let api_version = obj
.get("apiVersion") .get("apiVersion")
@@ -164,15 +164,15 @@ pub async fn kube_apply(manifest: &str) -> Result<()> {
let patch: serde_json::Value = serde_json::from_str( let patch: serde_json::Value = serde_json::from_str(
&serde_json::to_string( &serde_json::to_string(
&serde_yaml::from_str::<serde_json::Value>(doc) &serde_yaml::from_str::<serde_json::Value>(doc)
.context("Failed to parse YAML to JSON")?, .ctx("Failed to parse YAML to JSON")?,
) )
.context("Failed to serialize to JSON")?, .ctx("Failed to serialize to JSON")?,
) )
.context("Failed to parse JSON")?; .ctx("Failed to parse JSON")?;
api.patch(name, &ssapply, &Patch::Apply(patch)) api.patch(name, &ssapply, &Patch::Apply(patch))
.await .await
.with_context(|| format!("Failed to apply {kind}/{name}"))?; .with_ctx(|| format!("Failed to apply {kind}/{name}"))?;
} }
Ok(()) Ok(())
} }
@@ -194,7 +194,7 @@ async fn resolve_api_resource(
let disc = discovery::Discovery::new(client.clone()) let disc = discovery::Discovery::new(client.clone())
.run() .run()
.await .await
.context("API discovery failed")?; .ctx("API discovery failed")?;
for api_group in disc.groups() { for api_group in disc.groups() {
if api_group.name() == group { if api_group.name() == group {
@@ -216,7 +216,7 @@ pub async fn kube_get_secret(ns: &str, name: &str) -> Result<Option<Secret>> {
let api: Api<Secret> = Api::namespaced(client.clone(), ns); let api: Api<Secret> = Api::namespaced(client.clone(), ns);
match api.get_opt(name).await { match api.get_opt(name).await {
Ok(secret) => Ok(secret), Ok(secret) => Ok(secret),
Err(e) => Err(e).context(format!("Failed to get secret {ns}/{name}")), Err(e) => Err(e).with_ctx(|| format!("Failed to get secret {ns}/{name}")),
} }
} }
@@ -225,16 +225,16 @@ pub async fn kube_get_secret(ns: &str, name: &str) -> Result<Option<Secret>> {
pub async fn kube_get_secret_field(ns: &str, name: &str, key: &str) -> Result<String> { pub async fn kube_get_secret_field(ns: &str, name: &str, key: &str) -> Result<String> {
let secret = kube_get_secret(ns, name) let secret = kube_get_secret(ns, name)
.await? .await?
.with_context(|| format!("Secret {ns}/{name} not found"))?; .with_ctx(|| format!("Secret {ns}/{name} not found"))?;
let data = secret.data.as_ref().context("Secret has no data")?; let data = secret.data.as_ref().ctx("Secret has no data")?;
let bytes = data let bytes = data
.get(key) .get(key)
.with_context(|| format!("Key {key:?} not found in secret {ns}/{name}"))?; .with_ctx(|| format!("Key {key:?} not found in secret {ns}/{name}"))?;
String::from_utf8(bytes.0.clone()) String::from_utf8(bytes.0.clone())
.with_context(|| format!("Key {key:?} in secret {ns}/{name} is not valid UTF-8")) .with_ctx(|| format!("Key {key:?} in secret {ns}/{name} is not valid UTF-8"))
} }
/// Check if a namespace exists. /// Check if a namespace exists.
@@ -245,7 +245,7 @@ pub async fn ns_exists(ns: &str) -> Result<bool> {
match api.get_opt(ns).await { match api.get_opt(ns).await {
Ok(Some(_)) => Ok(true), Ok(Some(_)) => Ok(true),
Ok(None) => Ok(false), Ok(None) => Ok(false),
Err(e) => Err(e).context(format!("Failed to check namespace {ns}")), Err(e) => Err(e).with_ctx(|| format!("Failed to check namespace {ns}")),
} }
} }
@@ -265,7 +265,7 @@ pub async fn ensure_ns(ns: &str) -> Result<()> {
let pp = PatchParams::apply("sunbeam").force(); let pp = PatchParams::apply("sunbeam").force();
api.patch(ns, &pp, &Patch::Apply(ns_obj)) api.patch(ns, &pp, &Patch::Apply(ns_obj))
.await .await
.with_context(|| format!("Failed to create namespace {ns}"))?; .with_ctx(|| format!("Failed to create namespace {ns}"))?;
Ok(()) Ok(())
} }
@@ -296,7 +296,7 @@ pub async fn create_secret(ns: &str, name: &str, data: HashMap<String, String>)
let pp = PatchParams::apply("sunbeam").force(); let pp = PatchParams::apply("sunbeam").force();
api.patch(name, &pp, &Patch::Apply(secret_obj)) api.patch(name, &pp, &Patch::Apply(secret_obj))
.await .await
.with_context(|| format!("Failed to create/update secret {ns}/{name}"))?; .with_ctx(|| format!("Failed to create/update secret {ns}/{name}"))?;
Ok(()) Ok(())
} }
@@ -323,12 +323,12 @@ pub async fn kube_exec(
let mut attached = pods let mut attached = pods
.exec(pod, cmd_strings, &ep) .exec(pod, cmd_strings, &ep)
.await .await
.with_context(|| format!("Failed to exec in pod {ns}/{pod}"))?; .with_ctx(|| format!("Failed to exec in pod {ns}/{pod}"))?;
let stdout = { let stdout = {
let mut stdout_reader = attached let mut stdout_reader = attached
.stdout() .stdout()
.context("No stdout stream from exec")?; .ctx("No stdout stream from exec")?;
let mut buf = Vec::new(); let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut stdout_reader, &mut buf).await?; tokio::io::AsyncReadExt::read_to_end(&mut stdout_reader, &mut buf).await?;
String::from_utf8_lossy(&buf).to_string() String::from_utf8_lossy(&buf).to_string()
@@ -336,7 +336,7 @@ pub async fn kube_exec(
let status = attached let status = attached
.take_status() .take_status()
.context("No status channel from exec")?; .ctx("No status channel from exec")?;
// Wait for the status // Wait for the status
let exit_code = if let Some(status) = status.await { let exit_code = if let Some(status) = status.await {
@@ -372,7 +372,7 @@ pub async fn kube_rollout_restart(ns: &str, deployment: &str) -> Result<()> {
api.patch(deployment, &PatchParams::default(), &Patch::Strategic(patch)) api.patch(deployment, &PatchParams::default(), &Patch::Strategic(patch))
.await .await
.with_context(|| format!("Failed to restart deployment {ns}/{deployment}"))?; .with_ctx(|| format!("Failed to restart deployment {ns}/{deployment}"))?;
Ok(()) Ok(())
} }
@@ -485,14 +485,14 @@ pub async fn kustomize_build(overlay: &Path, domain: &str, email: &str) -> Resul
.env("PATH", &env_path) .env("PATH", &env_path)
.output() .output()
.await .await
.context("Failed to run kustomize")?; .ctx("Failed to run kustomize")?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
bail!("kustomize build failed: {stderr}"); bail!("kustomize build failed: {stderr}");
} }
let mut text = String::from_utf8(output.stdout).context("kustomize output not UTF-8")?; let mut text = String::from_utf8(output.stdout).ctx("kustomize output not UTF-8")?;
// Domain substitution // Domain substitution
text = domain_replace(&text, domain); text = domain_replace(&text, domain);
@@ -565,7 +565,7 @@ pub async fn cmd_k8s(kubectl_args: &[String]) -> Result<()> {
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.status() .status()
.await .await
.context("Failed to run kubectl")?; .ctx("Failed to run kubectl")?;
if !status.success() { if !status.success() {
std::process::exit(status.code().unwrap_or(1)); std::process::exit(status.code().unwrap_or(1));
@@ -580,18 +580,18 @@ pub async fn cmd_bao(bao_args: &[String]) -> Result<()> {
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(client.clone(), "data"); let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(client.clone(), "data");
let lp = ListParams::default().labels("app.kubernetes.io/name=openbao"); let lp = ListParams::default().labels("app.kubernetes.io/name=openbao");
let pod_list = pods.list(&lp).await.context("Failed to list OpenBao pods")?; let pod_list = pods.list(&lp).await.ctx("Failed to list OpenBao pods")?;
let ob_pod = pod_list let ob_pod = pod_list
.items .items
.first() .first()
.and_then(|p| p.metadata.name.as_deref()) .and_then(|p| p.metadata.name.as_deref())
.context("OpenBao pod not found -- is the cluster running?")? .ctx("OpenBao pod not found -- is the cluster running?")?
.to_string(); .to_string();
// Get root token // Get root token
let root_token = kube_get_secret_field("data", "openbao-keys", "root-token") let root_token = kube_get_secret_field("data", "openbao-keys", "root-token")
.await .await
.context("root-token not found in openbao-keys secret")?; .ctx("root-token not found in openbao-keys secret")?;
// Build the command string for sh -c // Build the command string for sh -c
let bao_arg_str = bao_args.join(" "); let bao_arg_str = bao_args.join(" ");
@@ -606,7 +606,7 @@ pub async fn cmd_bao(bao_args: &[String]) -> Result<()> {
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.status() .status()
.await .await
.context("Failed to run bao in OpenBao pod")?; .ctx("Failed to run bao in OpenBao pod")?;
if !status.success() { if !status.success() {
std::process::exit(status.code().unwrap_or(1)); std::process::exit(status.code().unwrap_or(1));

View File

@@ -3,7 +3,7 @@
//! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the //! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the
//! Python version with direct HTTP API calls via port-forward to openbao:8200. //! Python version with direct HTTP API calls via port-forward to openbao:8200.
use anyhow::{bail, Context, Result}; use crate::error::{Result, ResultExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -96,13 +96,13 @@ impl BaoClient {
.get(format!("{}/v1/sys/seal-status", self.base_url)) .get(format!("{}/v1/sys/seal-status", self.base_url))
.send() .send()
.await .await
.context("Failed to connect to OpenBao")?; .ctx("Failed to connect to OpenBao")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
bail!("OpenBao seal-status returned {status}: {body}"); bail!("OpenBao seal-status returned {status}: {body}");
} }
resp.json().await.context("Failed to parse seal status") resp.json().await.ctx("Failed to parse seal status")
} }
/// Initialize OpenBao with the given number of key shares and threshold. /// Initialize OpenBao with the given number of key shares and threshold.
@@ -122,14 +122,14 @@ impl BaoClient {
}) })
.send() .send()
.await .await
.context("Failed to initialize OpenBao")?; .ctx("Failed to initialize OpenBao")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
bail!("OpenBao init returned {status}: {body}"); bail!("OpenBao init returned {status}: {body}");
} }
resp.json().await.context("Failed to parse init response") resp.json().await.ctx("Failed to parse init response")
} }
/// Unseal OpenBao with one key share. /// Unseal OpenBao with one key share.
@@ -145,14 +145,14 @@ impl BaoClient {
.json(&UnsealRequest { key }) .json(&UnsealRequest { key })
.send() .send()
.await .await
.context("Failed to unseal OpenBao")?; .ctx("Failed to unseal OpenBao")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
bail!("OpenBao unseal returned {status}: {body}"); bail!("OpenBao unseal returned {status}: {body}");
} }
resp.json().await.context("Failed to parse unseal response") resp.json().await.ctx("Failed to parse unseal response")
} }
// ── Secrets engine management ─────────────────────────────────────── // ── Secrets engine management ───────────────────────────────────────
@@ -172,7 +172,7 @@ impl BaoClient {
}) })
.send() .send()
.await .await
.context("Failed to enable secrets engine")?; .ctx("Failed to enable secrets engine")?;
let status = resp.status(); let status = resp.status();
if status.is_success() || status.as_u16() == 400 { if status.is_success() || status.as_u16() == 400 {
@@ -193,7 +193,7 @@ impl BaoClient {
.request(reqwest::Method::GET, &format!("{mount}/data/{path}")) .request(reqwest::Method::GET, &format!("{mount}/data/{path}"))
.send() .send()
.await .await
.context("Failed to read KV secret")?; .ctx("Failed to read KV secret")?;
if resp.status().as_u16() == 404 { if resp.status().as_u16() == 404 {
return Ok(None); return Ok(None);
@@ -204,7 +204,7 @@ impl BaoClient {
bail!("KV get {mount}/{path} returned {status}: {body}"); bail!("KV get {mount}/{path} returned {status}: {body}");
} }
let kv_resp: KvReadResponse = resp.json().await.context("Failed to parse KV response")?; let kv_resp: KvReadResponse = resp.json().await.ctx("Failed to parse KV response")?;
let data = kv_resp let data = kv_resp
.data .data
.and_then(|d| d.data) .and_then(|d| d.data)
@@ -251,7 +251,7 @@ impl BaoClient {
.json(&KvWriteRequest { data }) .json(&KvWriteRequest { data })
.send() .send()
.await .await
.context("Failed to write KV secret")?; .ctx("Failed to write KV secret")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -279,7 +279,7 @@ impl BaoClient {
.json(&KvWriteRequest { data }) .json(&KvWriteRequest { data })
.send() .send()
.await .await
.context("Failed to patch KV secret")?; .ctx("Failed to patch KV secret")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -295,7 +295,7 @@ impl BaoClient {
.request(reqwest::Method::DELETE, &format!("{mount}/data/{path}")) .request(reqwest::Method::DELETE, &format!("{mount}/data/{path}"))
.send() .send()
.await .await
.context("Failed to delete KV secret")?; .ctx("Failed to delete KV secret")?;
// 404 is fine (already deleted) // 404 is fine (already deleted)
if !resp.status().is_success() && resp.status().as_u16() != 404 { if !resp.status().is_success() && resp.status().as_u16() != 404 {
@@ -323,7 +323,7 @@ impl BaoClient {
}) })
.send() .send()
.await .await
.context("Failed to enable auth method")?; .ctx("Failed to enable auth method")?;
let status = resp.status(); let status = resp.status();
if status.is_success() || status.as_u16() == 400 { if status.is_success() || status.as_u16() == 400 {
@@ -349,7 +349,7 @@ impl BaoClient {
.json(&PolicyRequest { policy: policy_hcl }) .json(&PolicyRequest { policy: policy_hcl })
.send() .send()
.await .await
.context("Failed to write policy")?; .ctx("Failed to write policy")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -370,7 +370,7 @@ impl BaoClient {
.json(data) .json(data)
.send() .send()
.await .await
.with_context(|| format!("Failed to write to {path}"))?; .with_ctx(|| format!("Failed to write to {path}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -382,7 +382,7 @@ impl BaoClient {
if body.is_empty() { if body.is_empty() {
Ok(serde_json::Value::Null) Ok(serde_json::Value::Null)
} else { } else {
serde_json::from_str(&body).context("Failed to parse write response") serde_json::from_str(&body).ctx("Failed to parse write response")
} }
} }
@@ -392,7 +392,7 @@ impl BaoClient {
.request(reqwest::Method::GET, path) .request(reqwest::Method::GET, path)
.send() .send()
.await .await
.with_context(|| format!("Failed to read {path}"))?; .with_ctx(|| format!("Failed to read {path}"))?;
if resp.status().as_u16() == 404 { if resp.status().as_u16() == 404 {
return Ok(None); return Ok(None);

View File

@@ -1,6 +1,6 @@
//! Service management — status, logs, restart. //! Service management — status, logs, restart.
use anyhow::{bail, Result}; use crate::error::{Result, SunbeamError};
use k8s_openapi::api::core::v1::Pod; use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, DynamicObject, ListParams, LogParams}; use kube::api::{Api, DynamicObject, ListParams, LogParams};
use kube::ResourceExt; use kube::ResourceExt;
@@ -397,7 +397,7 @@ pub async fn cmd_get(target: &str, output: &str) -> Result<()> {
let pod = api let pod = api
.get_opt(name) .get_opt(name)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("Pod {ns}/{name} not found."))?; .ok_or_else(|| SunbeamError::kube(format!("Pod {ns}/{name} not found.")))?;
let text = match output { let text = match output {
"json" => serde_json::to_string_pretty(&pod)?, "json" => serde_json::to_string_pretty(&pod)?,

View File

@@ -1,4 +1,4 @@
use anyhow::{Context, Result}; use crate::error::{Result, ResultExt};
use std::path::PathBuf; use std::path::PathBuf;
static KUSTOMIZE_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/kustomize")); static KUSTOMIZE_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/kustomize"));
@@ -15,7 +15,7 @@ fn cache_dir() -> PathBuf {
fn extract_embedded(data: &[u8], name: &str) -> Result<PathBuf> { fn extract_embedded(data: &[u8], name: &str) -> Result<PathBuf> {
let dir = cache_dir(); let dir = cache_dir();
std::fs::create_dir_all(&dir) std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create cache dir: {}", dir.display()))?; .with_ctx(|| format!("Failed to create cache dir: {}", dir.display()))?;
let dest = dir.join(name); let dest = dir.join(name);
@@ -29,7 +29,7 @@ fn extract_embedded(data: &[u8], name: &str) -> Result<PathBuf> {
} }
std::fs::write(&dest, data) std::fs::write(&dest, data)
.with_context(|| format!("Failed to write {}", dest.display()))?; .with_ctx(|| format!("Failed to write {}", dest.display()))?;
#[cfg(unix)] #[cfg(unix)]
{ {

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Context, Result}; use crate::error::{Result, ResultExt};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -141,7 +141,7 @@ pub async fn cmd_update() -> Result<()> {
let binary_artifact = artifacts let binary_artifact = artifacts
.iter() .iter()
.find(|a| a.name == wanted) .find(|a| a.name == wanted)
.with_context(|| format!("No artifact found for platform '{wanted}'"))?; .with_ctx(|| format!("No artifact found for platform '{wanted}'"))?;
let checksums_artifact = artifacts let checksums_artifact = artifacts
.iter() .iter()
@@ -157,7 +157,7 @@ pub async fn cmd_update() -> Result<()> {
.send() .send()
.await? .await?
.error_for_status() .error_for_status()
.context("Failed to download binary artifact")? .ctx("Failed to download binary artifact")?
.bytes() .bytes()
.await?; .await?;
@@ -174,7 +174,7 @@ pub async fn cmd_update() -> Result<()> {
.send() .send()
.await? .await?
.error_for_status() .error_for_status()
.context("Failed to download checksums")? .ctx("Failed to download checksums")?
.text() .text()
.await?; .await?;
@@ -186,7 +186,7 @@ pub async fn cmd_update() -> Result<()> {
// 5. Atomic self-replace // 5. Atomic self-replace
crate::output::step("Installing update..."); crate::output::step("Installing update...");
let current_exe = std::env::current_exe().context("Failed to determine current executable path")?; let current_exe = std::env::current_exe().ctx("Failed to determine current executable path")?;
atomic_replace(&current_exe, &binary_bytes)?; atomic_replace(&current_exe, &binary_bytes)?;
crate::output::ok(&format!( crate::output::ok(&format!(
@@ -273,7 +273,7 @@ async fn fetch_latest_commit(client: &reqwest::Client, forge_url: &str) -> Resul
.send() .send()
.await? .await?
.error_for_status() .error_for_status()
.context("Failed to query mainline branch")? .ctx("Failed to query mainline branch")?
.json() .json()
.await?; .await?;
Ok(resp.commit.id) Ok(resp.commit.id)
@@ -287,7 +287,7 @@ async fn fetch_artifacts(client: &reqwest::Client, forge_url: &str) -> Result<Ve
.send() .send()
.await? .await?
.error_for_status() .error_for_status()
.context("Failed to query CI artifacts")? .ctx("Failed to query CI artifacts")?
.json() .json()
.await?; .await?;
Ok(resp.artifacts) Ok(resp.artifacts)
@@ -329,23 +329,23 @@ fn verify_checksum(binary: &[u8], artifact_name: &str, checksums_text: &str) ->
fn atomic_replace(target: &std::path::Path, new_bytes: &[u8]) -> Result<()> { fn atomic_replace(target: &std::path::Path, new_bytes: &[u8]) -> Result<()> {
let parent = target let parent = target
.parent() .parent()
.context("Cannot determine parent directory of current executable")?; .ctx("Cannot determine parent directory of current executable")?;
let tmp_path = parent.join(".sunbeam-update.tmp"); let tmp_path = parent.join(".sunbeam-update.tmp");
// Write new binary // Write new binary
fs::write(&tmp_path, new_bytes).context("Failed to write temporary update file")?; fs::write(&tmp_path, new_bytes).ctx("Failed to write temporary update file")?;
// Set executable permissions (unix) // Set executable permissions (unix)
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))
.context("Failed to set executable permissions")?; .ctx("Failed to set executable permissions")?;
} }
// Atomic rename // Atomic rename
fs::rename(&tmp_path, target).context("Failed to replace current executable")?; fs::rename(&tmp_path, target).ctx("Failed to replace current executable")?;
Ok(()) Ok(())
} }

View File

@@ -1,9 +1,9 @@
//! User management -- Kratos identity operations via port-forwarded admin API. //! User management -- Kratos identity operations via port-forwarded admin API.
use anyhow::{bail, Context, Result};
use serde_json::Value; use serde_json::Value;
use std::io::Write; use std::io::Write;
use crate::error::{Result, ResultExt, SunbeamError};
use crate::output::{ok, step, table, warn}; use crate::output::{ok, step, table, warn};
const SMTP_LOCAL_PORT: u16 = 10025; const SMTP_LOCAL_PORT: u16 = 10025;
@@ -33,7 +33,7 @@ fn spawn_port_forward(
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
.spawn() .spawn()
.with_context(|| format!("Failed to spawn port-forward to {ns}/svc/{svc}"))?; .with_ctx(|| format!("Failed to spawn port-forward to {ns}/svc/{svc}"))?;
// Give the port-forward time to bind // Give the port-forward time to bind
std::thread::sleep(std::time::Duration::from_millis(1500)); std::thread::sleep(std::time::Duration::from_millis(1500));
@@ -99,7 +99,7 @@ fn api(
req = req.json(b); req = req.json(b);
} }
let resp = req.send().with_context(|| format!("HTTP {method} {url} failed"))?; let resp = req.send().with_ctx(|| format!("HTTP {method} {url} failed"))?;
let status = resp.status().as_u16(); let status = resp.status().as_u16();
if !resp.status().is_success() { if !resp.status().is_success() {
@@ -115,7 +115,7 @@ fn api(
return Ok(None); return Ok(None);
} }
let val: Value = serde_json::from_str(&text) let val: Value = serde_json::from_str(&text)
.with_context(|| format!("Failed to parse API response as JSON: {text}"))?; .with_ctx(|| format!("Failed to parse API response as JSON: {text}"))?;
Ok(Some(val)) Ok(Some(val))
} }
@@ -158,7 +158,7 @@ fn find_identity(base_url: &str, target: &str, required: bool) -> Result<Option<
} }
if required { if required {
bail!("Identity not found: {target}"); return Err(SunbeamError::identity(format!("Identity not found: {target}")));
} }
Ok(None) Ok(None)
} }
@@ -290,7 +290,7 @@ fn identity_id(identity: &Value) -> Result<String> {
.get("id") .get("id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.context("Identity missing 'id' field") .ok_or_else(|| SunbeamError::identity("Identity missing 'id' field"))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -345,7 +345,7 @@ pub async fn cmd_user_get(target: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
drop(pf); drop(pf);
println!("{}", serde_json::to_string_pretty(&identity)?); println!("{}", serde_json::to_string_pretty(&identity)?);
@@ -372,7 +372,7 @@ pub async fn cmd_user_create(email: &str, name: &str, schema_id: &str) -> Result
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])? let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])?
.context("Failed to create identity")?; .ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
ok(&format!("Created identity: {iid}")); ok(&format!("Created identity: {iid}"));
@@ -401,7 +401,7 @@ pub async fn cmd_user_delete(target: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
kratos_api( kratos_api(
&pf.base_url, &pf.base_url,
@@ -421,7 +421,7 @@ pub async fn cmd_user_recover(target: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
let (link, code) = generate_recovery(&pf.base_url, &iid)?; let (link, code) = generate_recovery(&pf.base_url, &iid)?;
drop(pf); drop(pf);
@@ -438,7 +438,7 @@ pub async fn cmd_user_disable(target: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
let put_body = identity_put_body(&identity, Some("inactive"), None); let put_body = identity_put_body(&identity, Some("inactive"), None);
@@ -471,7 +471,7 @@ pub async fn cmd_user_enable(target: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
let put_body = identity_put_body(&identity, Some("active"), None); let put_body = identity_put_body(&identity, Some("active"), None);
@@ -493,7 +493,7 @@ pub async fn cmd_user_set_password(target: &str, password: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
let extra = serde_json::json!({ let extra = serde_json::json!({
@@ -577,15 +577,17 @@ Messages (Matrix):
let from: Mailbox = format!("Sunbeam Studios <noreply@{domain}>") let from: Mailbox = format!("Sunbeam Studios <noreply@{domain}>")
.parse() .parse()
.context("Invalid from address")?; .map_err(|e| SunbeamError::Other(format!("Invalid from address: {e}")))?;
let to: Mailbox = email.parse().context("Invalid recipient address")?; let to: Mailbox = email
.parse()
.map_err(|e| SunbeamError::Other(format!("Invalid recipient address: {e}")))?;
let message = Message::builder() let message = Message::builder()
.from(from) .from(from)
.to(to) .to(to)
.subject("Welcome to Sunbeam Studios -- Set Your Password") .subject("Welcome to Sunbeam Studios -- Set Your Password")
.body(body_text) .body(body_text)
.context("Failed to build email message")?; .ctx("Failed to build email message")?;
let _pf = PortForward::new("lasuite", "postfix", SMTP_LOCAL_PORT, 25)?; let _pf = PortForward::new("lasuite", "postfix", SMTP_LOCAL_PORT, 25)?;
@@ -595,7 +597,7 @@ Messages (Matrix):
mailer mailer
.send(&message) .send(&message)
.context("Failed to send welcome email via SMTP")?; .ctx("Failed to send welcome email via SMTP")?;
ok(&format!("Welcome email sent to {email}")); ok(&format!("Welcome email sent to {email}"));
Ok(()) Ok(())
@@ -669,7 +671,7 @@ pub async fn cmd_user_onboard(
}); });
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])? let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])?
.context("Failed to create identity")?; .ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
ok(&format!("Created identity: {iid}")); ok(&format!("Created identity: {iid}"));
@@ -729,7 +731,7 @@ pub async fn cmd_user_offboard(target: &str) -> Result<()> {
let pf = PortForward::kratos()?; let pf = PortForward::kratos()?;
let identity = find_identity(&pf.base_url, target, true)? let identity = find_identity(&pf.base_url, target, true)?
.context("Identity not found")?; .ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?; let iid = identity_id(&identity)?;
step("Disabling identity..."); step("Disabling identity...");