diff --git a/src/checks.rs b/src/checks.rs index b51f574..f40e899 100644 --- a/src/checks.rs +++ b/src/checks.rs @@ -1,6 +1,6 @@ //! Service-level health checks — functional probes beyond pod readiness. -use anyhow::Result; +use crate::error::Result; use base64::Engine; use hmac::{Hmac, Mac}; use k8s_openapi::api::core::v1::Pod; @@ -87,7 +87,7 @@ async fn http_get( client: &reqwest::Client, url: &str, headers: Option<&[(&str, &str)]>, -) -> Result<(u16, Vec), String> { +) -> std::result::Result<(u16, Vec), String> { let mut req = client.get(url); if let Some(hdrs) = headers { for (k, v) in hdrs { diff --git a/src/cli.rs b/src/cli.rs index 2939827..8f1f4e6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use crate::error::{Result, SunbeamError}; use clap::{Parser, Subcommand, ValueEnum}; /// Sunbeam local dev stack manager. @@ -309,7 +309,7 @@ pub enum UserAction { }, } -fn validate_date(s: &str) -> Result { +fn validate_date(s: &str) -> std::result::Result { if s.is_empty() { return Ok(s.to_string()); } @@ -672,10 +672,10 @@ pub async fn dispatch() -> Result<()> { Env::Production => { let host = crate::config::get_production_host(); if host.is_empty() { - bail!( + return Err(SunbeamError::config( "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) } diff --git a/src/cluster.rs b/src/cluster.rs index 8786446..fdedd88 100644 --- a/src/cluster.rs +++ b/src/cluster.rs @@ -2,7 +2,7 @@ //! //! Pure K8s implementation: no Lima VM operations. -use anyhow::{bail, Context, Result}; +use crate::error::{Result, ResultExt, SunbeamError}; use std::path::PathBuf; const GITEA_ADMIN_USER: &str = "gitea_admin"; @@ -36,10 +36,10 @@ async fn ensure_cert_manager() -> Result<()> { // Download and apply cert-manager YAML let body = reqwest::get(CERT_MANAGER_URL) .await - .context("Failed to download cert-manager manifest")? + .ctx("Failed to download cert-manager manifest")? .text() .await - .context("Failed to read cert-manager manifest body")?; + .ctx("Failed to read cert-manager manifest body")?; crate::kube::kube_apply(&body).await?; @@ -73,7 +73,7 @@ async fn ensure_linkerd() -> Result<()> { crate::output::ok("Installing Gateway API CRDs..."); let gateway_body = reqwest::get(GATEWAY_API_CRDS_URL) .await - .context("Failed to download Gateway API CRDs")? + .ctx("Failed to download Gateway API CRDs")? .text() .await?; @@ -86,11 +86,11 @@ async fn ensure_linkerd() -> Result<()> { .args(["install", "--crds"]) .output() .await - .context("Failed to run `linkerd install --crds`")?; + .ctx("Failed to run `linkerd install --crds`")?; if !crds_output.status.success() { 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); crate::kube::kube_apply(&crds).await?; @@ -101,11 +101,11 @@ async fn ensure_linkerd() -> Result<()> { .args(["install"]) .output() .await - .context("Failed to run `linkerd install`")?; + .ctx("Failed to run `linkerd install`")?; if !cp_output.status.success() { 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); 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}...")); 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 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 .distinguished_name .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 .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()) - .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()) - .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}")); Ok(()) @@ -176,9 +177,9 @@ async fn ensure_tls_secret(domain: &str) -> Result<()> { let dir = secrets_dir(); 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 = - 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 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(); api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj)) .await - .context("Failed to create TLS secret")?; + .ctx("Failed to create TLS secret")?; crate::output::ok("Done."); Ok(()) @@ -289,7 +290,7 @@ async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) -> Result<( loop { 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? { diff --git a/src/config.rs b/src/config.rs index 7f97e16..65205f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use crate::error::{Result, ResultExt}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -47,7 +47,7 @@ pub fn load_config() -> SunbeamConfig { pub fn save_config(config: &SunbeamConfig) -> Result<()> { let path = config_path(); if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).with_context(|| { + std::fs::create_dir_all(parent).with_ctx(|| { format!( "Failed to create config directory: {}", parent.display() @@ -56,7 +56,7 @@ pub fn save_config(config: &SunbeamConfig) -> Result<()> { } let content = serde_json::to_string_pretty(config)?; 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())); Ok(()) } @@ -114,7 +114,7 @@ pub fn clear_config() -> Result<()> { let path = config_path(); if path.exists() { 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!( "Configuration cleared from {}", path.display() diff --git a/src/gitea.rs b/src/gitea.rs index f23bb49..b94924c 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,6 +1,6 @@ //! 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 kube::api::{Api, ListParams}; use serde_json::Value; diff --git a/src/images.rs b/src/images.rs index e364d3f..6064a87 100644 --- a/src/images.rs +++ b/src/images.rs @@ -1,6 +1,6 @@ //! Image building, mirroring, and pushing to Gitea registry. -use anyhow::{bail, Context, Result}; +use crate::error::{Result, ResultExt, SunbeamError}; use base64::Engine; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -86,7 +86,7 @@ async fn get_build_env() -> Result { "password", ) .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 { "linux/amd64".to_string() @@ -131,7 +131,7 @@ async fn buildctl_build_and_push( ) -> Result<()> { // Find a free local port for port-forward 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(); 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"); 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 let ctx_arg = format!("--context={}", crate::kube::context()); @@ -165,14 +165,14 @@ async fn buildctl_build_and_push( .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .context("Failed to start buildkitd port-forward")?; + .ctx("Failed to start buildkitd port-forward")?; // Wait for port-forward to become ready let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if tokio::time::Instant::now() > deadline { 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}")) .await @@ -247,8 +247,8 @@ async fn buildctl_build_and_push( match result { Ok(status) if status.success() => Ok(()), - Ok(status) => bail!("buildctl exited with status {status}"), - Err(e) => bail!("Failed to run buildctl: {e}"), + Ok(status) => return Err(SunbeamError::tool("buildctl", format!("exited with status {status}"))), + Err(e) => return Err(SunbeamError::tool("buildctl", format!("failed to run: {e}"))), } } @@ -320,7 +320,7 @@ async fn get_node_addresses() -> Result> { let node_list = api .list(&kube::api::ListParams::default()) .await - .context("Failed to list nodes")?; + .ctx("Failed to list nodes")?; let mut addresses = Vec::new(); for node in &node_list.items { @@ -387,7 +387,7 @@ async fn ctr_pull_on_nodes(env: &BuildEnv, images: &[String]) -> Result<()> { match status { 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 { 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? { @@ -477,10 +477,10 @@ async fn docker_hub_token(repo: &str) -> Result { ); let resp: DockerAuthToken = reqwest::get(&url) .await - .context("Failed to fetch Docker Hub token")? + .ctx("Failed to fetch Docker Hub token")? .json() .await - .context("Failed to parse Docker Hub token response")?; + .ctx("Failed to parse Docker Hub token response")?; Ok(resp.token) } @@ -502,18 +502,18 @@ async fn fetch_manifest_index( .header("Accept", accept) .send() .await - .context("Failed to fetch manifest from Docker Hub")?; + .ctx("Failed to fetch manifest from Docker Hub")?; if !resp.status().is_success() { - bail!( + return Err(SunbeamError::build(format!( "Docker Hub returned {} for {repo}:{tag}", resp.status() - ); + ))); } resp.json() .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 @@ -729,7 +729,7 @@ pub async fn cmd_mirror() -> Result<()> { .stdout(Stdio::null()) .stderr(Stdio::piped()) .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() { use tokio::io::AsyncWriteExt; @@ -854,7 +854,7 @@ async fn build_proxy(push: bool, deploy: bool) -> Result<()> { let env = get_build_env().await?; let proxy_dir = crate::config::get_repo_root().join("proxy"); 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); @@ -883,7 +883,7 @@ async fn build_tuwunel(push: bool, deploy: bool) -> Result<()> { let env = get_build_env().await?; let tuwunel_dir = crate::config::get_repo_root().join("tuwunel"); 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); @@ -916,10 +916,10 @@ async fn build_integration(push: bool, deploy: bool) -> Result<()> { let dockerignore = integration_service_dir.join(".dockerignore"); if !dockerfile.exists() { - bail!( + return Err(SunbeamError::build(format!( "integration-service Dockerfile not found at {}", dockerfile.display() - ); + ))); } if !sunbeam_dir .join("integration") @@ -927,11 +927,11 @@ async fn build_integration(push: bool, deploy: bool) -> Result<()> { .join("widgets") .is_dir() { - bail!( + return Err(SunbeamError::build(format!( "integration repo not found at {} -- \ run: cd sunbeam && git clone https://github.com/suitenumerique/integration.git", sunbeam_dir.join("integration").display() - ); + ))); } 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 kratos_admin_dir = crate::config::get_repo_root().join("kratos-admin"); if !kratos_admin_dir.is_dir() { - bail!( + return Err(SunbeamError::build(format!( "kratos-admin source not found at {}", kratos_admin_dir.display() - ); + ))); } 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 meet_dir = crate::config::get_repo_root().join("meet"); 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); @@ -1031,10 +1031,10 @@ async fn build_meet(push: bool, deploy: bool) -> Result<()> { step(&format!("Building meet-frontend -> {frontend_image} ...")); let frontend_dockerfile = meet_dir.join("src").join("frontend").join("Dockerfile"); if !frontend_dockerfile.exists() { - bail!( + return Err(SunbeamError::build(format!( "meet frontend Dockerfile not found at {}", frontend_dockerfile.display() - ); + ))); } 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 people_dir = crate::config::get_repo_root().join("people"); 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 app_dir = workspace_dir.join("apps").join("desk"); let dockerfile = workspace_dir.join("Dockerfile"); 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); @@ -1090,9 +1090,9 @@ async fn build_people(push: bool, deploy: bool) -> Result<()> { .current_dir(&workspace_dir) .status() .await - .context("Failed to run yarn install")?; + .ctx("Failed to run yarn install")?; if !yarn_status.success() { - bail!("yarn install failed"); + return Err(SunbeamError::tool("yarn", "install failed")); } // cunningham design tokens @@ -1106,9 +1106,9 @@ async fn build_people(push: bool, deploy: bool) -> Result<()> { .current_dir(&app_dir) .status() .await - .context("Failed to run cunningham")?; + .ctx("Failed to run cunningham")?; if !cunningham_status.success() { - bail!("cunningham failed"); + return Err(SunbeamError::tool("cunningham", "design token generation failed")); } 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 messages_dir = crate::config::get_repo_root().join("messages"); 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" { @@ -1278,10 +1278,10 @@ async fn build_la_suite_frontend( let dockerfile = repo_dir.join(dockerfile_rel); 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() { - 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); @@ -1293,9 +1293,9 @@ async fn build_la_suite_frontend( .current_dir(&workspace_dir) .status() .await - .context("Failed to run yarn install")?; + .ctx("Failed to run yarn install")?; if !yarn_status.success() { - bail!("yarn install failed"); + return Err(SunbeamError::tool("yarn", "install failed")); } ok("Regenerating cunningham design tokens (yarn build-theme)..."); @@ -1304,9 +1304,9 @@ async fn build_la_suite_frontend( .current_dir(&app_dir) .status() .await - .context("Failed to run yarn build-theme")?; + .ctx("Failed to run yarn build-theme")?; if !theme_status.success() { - bail!("yarn build-theme failed"); + return Err(SunbeamError::tool("yarn", "build-theme failed")); } let mut build_args = HashMap::new(); @@ -1338,7 +1338,7 @@ async fn patch_dockerfile_uv( platform: &str, ) -> Result<(PathBuf, Vec)> { 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/ let original_copy = content @@ -1408,7 +1408,7 @@ async fn patch_dockerfile_uv( // Download tarball let response = reqwest::get(&url) .await - .context("Failed to download uv release")?; + .ctx("Failed to download uv release")?; let tarball_bytes = response.bytes().await?; // 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 projects_dir = crate::config::get_repo_root().join("projects"); 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); @@ -1485,7 +1485,7 @@ async fn build_calendars(push: bool, deploy: bool) -> Result<()> { let env = get_build_env().await?; let cal_dir = crate::config::get_repo_root().join("calendars"); 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"); diff --git a/src/kube.rs b/src/kube.rs index a5025af..d72b740 100644 --- a/src/kube.rs +++ b/src/kube.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use crate::error::{Result, SunbeamError, ResultExt}; use base64::Engine; use k8s_openapi::api::apps::v1::Deployment; use k8s_openapi::api::core::v1::{Namespace, Secret}; @@ -71,7 +71,7 @@ pub async fn ensure_tunnel() -> Result<()> { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .context("Failed to spawn SSH tunnel")?; + .ctx("Failed to spawn SSH tunnel")?; // Wait for tunnel to become available for _ in 0..20 { @@ -98,15 +98,15 @@ pub async fn get_client() -> Result<&'static Client> { .get_or_try_init(|| async { 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 { context: Some(context().to_string()), ..Default::default() }; let config = Config::from_custom_kubeconfig(kubeconfig, &options) .await - .context("Failed to build kube config from kubeconfig")?; - Client::try_from(config).context("Failed to create kube client") + .map_err(|e| SunbeamError::kube(format!("Failed to build kube config from kubeconfig: {e}")))?; + Client::try_from(config).ctx("Failed to create kube client") }) .await } @@ -129,7 +129,7 @@ pub async fn kube_apply(manifest: &str) -> Result<()> { // Parse the YAML to a DynamicObject so we can route it 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 .get("apiVersion") @@ -164,15 +164,15 @@ pub async fn kube_apply(manifest: &str) -> Result<()> { let patch: serde_json::Value = serde_json::from_str( &serde_json::to_string( &serde_yaml::from_str::(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)) .await - .with_context(|| format!("Failed to apply {kind}/{name}"))?; + .with_ctx(|| format!("Failed to apply {kind}/{name}"))?; } Ok(()) } @@ -194,7 +194,7 @@ async fn resolve_api_resource( let disc = discovery::Discovery::new(client.clone()) .run() .await - .context("API discovery failed")?; + .ctx("API discovery failed")?; for api_group in disc.groups() { if api_group.name() == group { @@ -216,7 +216,7 @@ pub async fn kube_get_secret(ns: &str, name: &str) -> Result> { let api: Api = Api::namespaced(client.clone(), ns); match api.get_opt(name).await { 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> { pub async fn kube_get_secret_field(ns: &str, name: &str, key: &str) -> Result { let secret = kube_get_secret(ns, name) .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 .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()) - .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. @@ -245,7 +245,7 @@ pub async fn ns_exists(ns: &str) -> Result { match api.get_opt(ns).await { Ok(Some(_)) => Ok(true), 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(); api.patch(ns, &pp, &Patch::Apply(ns_obj)) .await - .with_context(|| format!("Failed to create namespace {ns}"))?; + .with_ctx(|| format!("Failed to create namespace {ns}"))?; Ok(()) } @@ -296,7 +296,7 @@ pub async fn create_secret(ns: &str, name: &str, data: HashMap) let pp = PatchParams::apply("sunbeam").force(); api.patch(name, &pp, &Patch::Apply(secret_obj)) .await - .with_context(|| format!("Failed to create/update secret {ns}/{name}"))?; + .with_ctx(|| format!("Failed to create/update secret {ns}/{name}"))?; Ok(()) } @@ -323,12 +323,12 @@ pub async fn kube_exec( let mut attached = pods .exec(pod, cmd_strings, &ep) .await - .with_context(|| format!("Failed to exec in pod {ns}/{pod}"))?; + .with_ctx(|| format!("Failed to exec in pod {ns}/{pod}"))?; let stdout = { let mut stdout_reader = attached .stdout() - .context("No stdout stream from exec")?; + .ctx("No stdout stream from exec")?; let mut buf = Vec::new(); tokio::io::AsyncReadExt::read_to_end(&mut stdout_reader, &mut buf).await?; String::from_utf8_lossy(&buf).to_string() @@ -336,7 +336,7 @@ pub async fn kube_exec( let status = attached .take_status() - .context("No status channel from exec")?; + .ctx("No status channel from exec")?; // Wait for the status 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)) .await - .with_context(|| format!("Failed to restart deployment {ns}/{deployment}"))?; + .with_ctx(|| format!("Failed to restart deployment {ns}/{deployment}"))?; Ok(()) } @@ -485,14 +485,14 @@ pub async fn kustomize_build(overlay: &Path, domain: &str, email: &str) -> Resul .env("PATH", &env_path) .output() .await - .context("Failed to run kustomize")?; + .ctx("Failed to run kustomize")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.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 text = domain_replace(&text, domain); @@ -565,7 +565,7 @@ pub async fn cmd_k8s(kubectl_args: &[String]) -> Result<()> { .stderr(Stdio::inherit()) .status() .await - .context("Failed to run kubectl")?; + .ctx("Failed to run kubectl")?; if !status.success() { std::process::exit(status.code().unwrap_or(1)); @@ -580,18 +580,18 @@ pub async fn cmd_bao(bao_args: &[String]) -> Result<()> { let pods: Api = Api::namespaced(client.clone(), "data"); 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 .items .first() .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(); // Get root token let root_token = kube_get_secret_field("data", "openbao-keys", "root-token") .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 let bao_arg_str = bao_args.join(" "); @@ -606,7 +606,7 @@ pub async fn cmd_bao(bao_args: &[String]) -> Result<()> { .stderr(Stdio::inherit()) .status() .await - .context("Failed to run bao in OpenBao pod")?; + .ctx("Failed to run bao in OpenBao pod")?; if !status.success() { std::process::exit(status.code().unwrap_or(1)); diff --git a/src/openbao.rs b/src/openbao.rs index b5f61da..729665e 100644 --- a/src/openbao.rs +++ b/src/openbao.rs @@ -3,7 +3,7 @@ //! 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. -use anyhow::{bail, Context, Result}; +use crate::error::{Result, ResultExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -96,13 +96,13 @@ impl BaoClient { .get(format!("{}/v1/sys/seal-status", self.base_url)) .send() .await - .context("Failed to connect to OpenBao")?; + .ctx("Failed to connect to OpenBao")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); 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. @@ -122,14 +122,14 @@ impl BaoClient { }) .send() .await - .context("Failed to initialize OpenBao")?; + .ctx("Failed to initialize OpenBao")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); 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. @@ -145,14 +145,14 @@ impl BaoClient { .json(&UnsealRequest { key }) .send() .await - .context("Failed to unseal OpenBao")?; + .ctx("Failed to unseal OpenBao")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); 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 ─────────────────────────────────────── @@ -172,7 +172,7 @@ impl BaoClient { }) .send() .await - .context("Failed to enable secrets engine")?; + .ctx("Failed to enable secrets engine")?; let status = resp.status(); if status.is_success() || status.as_u16() == 400 { @@ -193,7 +193,7 @@ impl BaoClient { .request(reqwest::Method::GET, &format!("{mount}/data/{path}")) .send() .await - .context("Failed to read KV secret")?; + .ctx("Failed to read KV secret")?; if resp.status().as_u16() == 404 { return Ok(None); @@ -204,7 +204,7 @@ impl BaoClient { 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 .data .and_then(|d| d.data) @@ -251,7 +251,7 @@ impl BaoClient { .json(&KvWriteRequest { data }) .send() .await - .context("Failed to write KV secret")?; + .ctx("Failed to write KV secret")?; if !resp.status().is_success() { let status = resp.status(); @@ -279,7 +279,7 @@ impl BaoClient { .json(&KvWriteRequest { data }) .send() .await - .context("Failed to patch KV secret")?; + .ctx("Failed to patch KV secret")?; if !resp.status().is_success() { let status = resp.status(); @@ -295,7 +295,7 @@ impl BaoClient { .request(reqwest::Method::DELETE, &format!("{mount}/data/{path}")) .send() .await - .context("Failed to delete KV secret")?; + .ctx("Failed to delete KV secret")?; // 404 is fine (already deleted) if !resp.status().is_success() && resp.status().as_u16() != 404 { @@ -323,7 +323,7 @@ impl BaoClient { }) .send() .await - .context("Failed to enable auth method")?; + .ctx("Failed to enable auth method")?; let status = resp.status(); if status.is_success() || status.as_u16() == 400 { @@ -349,7 +349,7 @@ impl BaoClient { .json(&PolicyRequest { policy: policy_hcl }) .send() .await - .context("Failed to write policy")?; + .ctx("Failed to write policy")?; if !resp.status().is_success() { let status = resp.status(); @@ -370,7 +370,7 @@ impl BaoClient { .json(data) .send() .await - .with_context(|| format!("Failed to write to {path}"))?; + .with_ctx(|| format!("Failed to write to {path}"))?; if !resp.status().is_success() { let status = resp.status(); @@ -382,7 +382,7 @@ impl BaoClient { if body.is_empty() { Ok(serde_json::Value::Null) } 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) .send() .await - .with_context(|| format!("Failed to read {path}"))?; + .with_ctx(|| format!("Failed to read {path}"))?; if resp.status().as_u16() == 404 { return Ok(None); diff --git a/src/services.rs b/src/services.rs index 8f52645..e3a8807 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,6 +1,6 @@ //! Service management — status, logs, restart. -use anyhow::{bail, Result}; +use crate::error::{Result, SunbeamError}; use k8s_openapi::api::core::v1::Pod; use kube::api::{Api, DynamicObject, ListParams, LogParams}; use kube::ResourceExt; @@ -397,7 +397,7 @@ pub async fn cmd_get(target: &str, output: &str) -> Result<()> { let pod = api .get_opt(name) .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 { "json" => serde_json::to_string_pretty(&pod)?, diff --git a/src/tools.rs b/src/tools.rs index 27776ea..ac025ab 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use crate::error::{Result, ResultExt}; use std::path::PathBuf; 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 { let dir = cache_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); @@ -29,7 +29,7 @@ fn extract_embedded(data: &[u8], name: &str) -> Result { } std::fs::write(&dest, data) - .with_context(|| format!("Failed to write {}", dest.display()))?; + .with_ctx(|| format!("Failed to write {}", dest.display()))?; #[cfg(unix)] { diff --git a/src/update.rs b/src/update.rs index 47ba03c..1863147 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use crate::error::{Result, ResultExt}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -141,7 +141,7 @@ pub async fn cmd_update() -> Result<()> { let binary_artifact = artifacts .iter() .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 .iter() @@ -157,7 +157,7 @@ pub async fn cmd_update() -> Result<()> { .send() .await? .error_for_status() - .context("Failed to download binary artifact")? + .ctx("Failed to download binary artifact")? .bytes() .await?; @@ -174,7 +174,7 @@ pub async fn cmd_update() -> Result<()> { .send() .await? .error_for_status() - .context("Failed to download checksums")? + .ctx("Failed to download checksums")? .text() .await?; @@ -186,7 +186,7 @@ pub async fn cmd_update() -> Result<()> { // 5. Atomic self-replace 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(¤t_exe, &binary_bytes)?; crate::output::ok(&format!( @@ -273,7 +273,7 @@ async fn fetch_latest_commit(client: &reqwest::Client, forge_url: &str) -> Resul .send() .await? .error_for_status() - .context("Failed to query mainline branch")? + .ctx("Failed to query mainline branch")? .json() .await?; Ok(resp.commit.id) @@ -287,7 +287,7 @@ async fn fetch_artifacts(client: &reqwest::Client, forge_url: &str) -> Result fn atomic_replace(target: &std::path::Path, new_bytes: &[u8]) -> Result<()> { let parent = target .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"); // 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) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) - .context("Failed to set executable permissions")?; + .ctx("Failed to set executable permissions")?; } // 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(()) } diff --git a/src/users.rs b/src/users.rs index ae267ef..0b33387 100644 --- a/src/users.rs +++ b/src/users.rs @@ -1,9 +1,9 @@ //! User management -- Kratos identity operations via port-forwarded admin API. -use anyhow::{bail, Context, Result}; use serde_json::Value; use std::io::Write; +use crate::error::{Result, ResultExt, SunbeamError}; use crate::output::{ok, step, table, warn}; const SMTP_LOCAL_PORT: u16 = 10025; @@ -33,7 +33,7 @@ fn spawn_port_forward( .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .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 std::thread::sleep(std::time::Duration::from_millis(1500)); @@ -99,7 +99,7 @@ fn api( 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(); if !resp.status().is_success() { @@ -115,7 +115,7 @@ fn api( return Ok(None); } 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)) } @@ -158,7 +158,7 @@ fn find_identity(base_url: &str, target: &str, required: bool) -> Result Result { .get("id") .and_then(|v| v.as_str()) .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 identity = find_identity(&pf.base_url, target, true)? - .context("Identity not found")?; + .ok_or_else(|| SunbeamError::identity("Identity not found"))?; drop(pf); 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 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)?; ok(&format!("Created identity: {iid}")); @@ -401,7 +401,7 @@ pub async fn cmd_user_delete(target: &str) -> Result<()> { let pf = PortForward::kratos()?; 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)?; kratos_api( &pf.base_url, @@ -421,7 +421,7 @@ pub async fn cmd_user_recover(target: &str) -> Result<()> { let pf = PortForward::kratos()?; 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 (link, code) = generate_recovery(&pf.base_url, &iid)?; drop(pf); @@ -438,7 +438,7 @@ pub async fn cmd_user_disable(target: &str) -> Result<()> { let pf = PortForward::kratos()?; 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 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 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 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 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 extra = serde_json::json!({ @@ -577,15 +577,17 @@ Messages (Matrix): let from: Mailbox = format!("Sunbeam Studios ") .parse() - .context("Invalid from address")?; - let to: Mailbox = email.parse().context("Invalid recipient address")?; + .map_err(|e| SunbeamError::Other(format!("Invalid from address: {e}")))?; + let to: Mailbox = email + .parse() + .map_err(|e| SunbeamError::Other(format!("Invalid recipient address: {e}")))?; let message = Message::builder() .from(from) .to(to) .subject("Welcome to Sunbeam Studios -- Set Your Password") .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)?; @@ -595,7 +597,7 @@ Messages (Matrix): mailer .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(()) @@ -669,7 +671,7 @@ pub async fn cmd_user_onboard( }); 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)?; ok(&format!("Created identity: {iid}")); @@ -729,7 +731,7 @@ pub async fn cmd_user_offboard(target: &str) -> Result<()> { let pf = PortForward::kratos()?; 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)?; step("Disabling identity...");