From 0c55be8d13a3b6e626b71fc6ddb6a0da2358a8cd Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Tue, 7 Apr 2026 19:26:11 +0100 Subject: [PATCH] refactor(sdk): remove lasuite from sunbeam-sdk --- sunbeam-sdk/Cargo.toml | 3 +- sunbeam-sdk/src/checks/mod.rs | 47 +- sunbeam-sdk/src/checks/probes.rs | 30 - sunbeam-sdk/src/client.rs | 94 +- sunbeam-sdk/src/constants.rs | 5 +- sunbeam-sdk/src/gitea/mod.rs | 4 +- sunbeam-sdk/src/images/builders.rs | 684 +---------- sunbeam-sdk/src/images/mod.rs | 171 +-- sunbeam-sdk/src/kube/mod.rs | 22 +- sunbeam-sdk/src/lasuite/calendars.rs | 191 ---- sunbeam-sdk/src/lasuite/cli.rs | 1512 ------------------------- sunbeam-sdk/src/lasuite/docs.rs | 170 --- sunbeam-sdk/src/lasuite/drive.rs | 249 ---- sunbeam-sdk/src/lasuite/find.rs | 77 -- sunbeam-sdk/src/lasuite/meet.rs | 132 --- sunbeam-sdk/src/lasuite/messages.rs | 166 --- sunbeam-sdk/src/lasuite/mod.rs | 21 - sunbeam-sdk/src/lasuite/people.rs | 178 --- sunbeam-sdk/src/lasuite/types.rs | 533 --------- sunbeam-sdk/src/lib.rs | 2 - sunbeam-sdk/src/manifests/mod.rs | 28 +- sunbeam-sdk/src/secrets/mod.rs | 42 +- sunbeam-sdk/src/secrets/seeding.rs | 20 +- sunbeam-sdk/src/services/mod.rs | 18 +- sunbeam-sdk/src/users/provisioning.rs | 234 +--- sunbeam-sdk/tests/test_lasuite.rs | 1018 ----------------- 26 files changed, 54 insertions(+), 5597 deletions(-) delete mode 100644 sunbeam-sdk/src/lasuite/calendars.rs delete mode 100644 sunbeam-sdk/src/lasuite/cli.rs delete mode 100644 sunbeam-sdk/src/lasuite/docs.rs delete mode 100644 sunbeam-sdk/src/lasuite/drive.rs delete mode 100644 sunbeam-sdk/src/lasuite/find.rs delete mode 100644 sunbeam-sdk/src/lasuite/meet.rs delete mode 100644 sunbeam-sdk/src/lasuite/messages.rs delete mode 100644 sunbeam-sdk/src/lasuite/mod.rs delete mode 100644 sunbeam-sdk/src/lasuite/people.rs delete mode 100644 sunbeam-sdk/src/lasuite/types.rs delete mode 100644 sunbeam-sdk/tests/test_lasuite.rs diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index 1fdebba1..4b026385 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -17,10 +17,9 @@ opensearch = [] s3 = [] livekit = [] monitoring = [] -lasuite = [] build = [] cli = ["dep:clap"] -all = ["identity", "gitea", "pm", "matrix", "opensearch", "s3", "livekit", "monitoring", "lasuite", "build"] +all = ["identity", "gitea", "pm", "matrix", "opensearch", "s3", "livekit", "monitoring", "build"] integration = ["all"] [dependencies] diff --git a/sunbeam-sdk/src/checks/mod.rs b/sunbeam-sdk/src/checks/mod.rs index c4e2b54f..bae1c0ab 100644 --- a/sunbeam-sdk/src/checks/mod.rs +++ b/sunbeam-sdk/src/checks/mod.rs @@ -161,16 +161,6 @@ fn check_registry() -> Vec { ns: "ory", svc: "hydra", }, - CheckEntry { - func: |d, c| Box::pin(check_people(d, c)), - ns: "lasuite", - svc: "people", - }, - CheckEntry { - func: |d, c| Box::pin(check_people_api(d, c)), - ns: "lasuite", - svc: "people", - }, CheckEntry { func: |d, c| Box::pin(check_livekit(d, c)), ns: "media", @@ -372,9 +362,9 @@ mod tests { #[test] fn test_check_registry_has_all_checks() { let registry = check_registry(); - assert_eq!(registry.len(), 11); + assert_eq!(registry.len(), 9); - // Verify order matches Python CHECKS list + // Verify order matches CHECKS list assert_eq!(registry[0].ns, "devtools"); assert_eq!(registry[0].svc, "gitea"); assert_eq!(registry[1].ns, "devtools"); @@ -391,12 +381,8 @@ mod tests { assert_eq!(registry[6].svc, "kratos"); assert_eq!(registry[7].ns, "ory"); assert_eq!(registry[7].svc, "hydra"); - assert_eq!(registry[8].ns, "lasuite"); - assert_eq!(registry[8].svc, "people"); - assert_eq!(registry[9].ns, "lasuite"); - assert_eq!(registry[9].svc, "people"); - assert_eq!(registry[10].ns, "media"); - assert_eq!(registry[10].svc, "livekit"); + assert_eq!(registry[8].ns, "media"); + assert_eq!(registry[8].svc, "livekit"); } #[test] @@ -516,12 +502,13 @@ mod tests { let registry = check_registry(); let namespaces: std::collections::HashSet<&str> = registry.iter().map(|e| e.ns).collect(); - for expected in &["devtools", "data", "storage", "ory", "lasuite", "media"] { + for expected in &["devtools", "data", "storage", "ory", "media"] { assert!( namespaces.contains(expected), "registry missing namespace: {expected}" ); } + assert!(!namespaces.contains("lasuite")); } #[test] @@ -531,13 +518,14 @@ mod tests { registry.iter().map(|e| e.svc).collect(); for expected in &[ "gitea", "postgres", "valkey", "openbao", "seaweedfs", "kratos", "hydra", - "people", "livekit", + "livekit", ] { assert!( services.contains(expected), "registry missing service: {expected}" ); } + assert!(!services.contains("people")); } #[test] @@ -550,16 +538,6 @@ mod tests { assert_eq!(gitea.len(), 2); } - #[test] - fn test_check_registry_lasuite_has_two_people_entries() { - let registry = check_registry(); - let people: Vec<_> = registry - .iter() - .filter(|e| e.ns == "lasuite" && e.svc == "people") - .collect(); - assert_eq!(people.len(), 2); - } - #[test] fn test_check_registry_data_has_three_entries() { let registry = check_registry(); @@ -585,7 +563,7 @@ mod tests { #[test] fn test_no_target_runs_all() { let selected = filter_registry(None, None); - assert_eq!(selected.len(), 11); + assert_eq!(selected.len(), 9); } #[test] @@ -616,13 +594,6 @@ mod tests { assert_eq!(selected[0], ("ory", "hydra")); } - #[test] - fn test_svc_filter_people_returns_both() { - let selected = filter_registry(Some("lasuite"), Some("people")); - assert_eq!(selected.len(), 2); - assert!(selected.iter().all(|(ns, svc)| *ns == "lasuite" && *svc == "people")); - } - #[test] fn test_filter_nonexistent_ns_returns_empty() { let selected = filter_registry(Some("nonexistent"), None); diff --git a/sunbeam-sdk/src/checks/probes.rs b/sunbeam-sdk/src/checks/probes.rs index f0bc3fa4..5edfd0ad 100644 --- a/sunbeam-sdk/src/checks/probes.rs +++ b/sunbeam-sdk/src/checks/probes.rs @@ -349,36 +349,6 @@ pub(super) async fn check_hydra_oidc(domain: &str, client: &reqwest::Client) -> } } -/// GET https://people.{domain}/ -> any response < 500 (302 to OIDC is fine). -pub(super) async fn check_people(domain: &str, client: &reqwest::Client) -> CheckResult { - let url = format!("https://people.{domain}/"); - match http_get(client, &url, None).await { - Ok((status, _)) => CheckResult { - name: "people".into(), - ns: "lasuite".into(), - svc: "people".into(), - passed: status < 500, - detail: format!("HTTP {status}"), - }, - Err(e) => CheckResult::fail("people", "lasuite", "people", &e), - } -} - -/// GET /api/v1.0/config/ -> any response < 500 (401 auth-required is fine). -pub(super) async fn check_people_api(domain: &str, client: &reqwest::Client) -> CheckResult { - let url = format!("https://people.{domain}/api/v1.0/config/"); - match http_get(client, &url, None).await { - Ok((status, _)) => CheckResult { - name: "people-api".into(), - ns: "lasuite".into(), - svc: "people".into(), - passed: status < 500, - detail: format!("HTTP {status}"), - }, - Err(e) => CheckResult::fail("people-api", "lasuite", "people", &e), - } -} - /// kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0. pub(super) async fn check_livekit(_domain: &str, _client: &reqwest::Client) -> CheckResult { let kube_client = match get_client().await { diff --git a/sunbeam-sdk/src/client.rs b/sunbeam-sdk/src/client.rs index c06f694f..8d927821 100644 --- a/sunbeam-sdk/src/client.rs +++ b/sunbeam-sdk/src/client.rs @@ -234,7 +234,7 @@ impl HttpTransport { /// constructing on first call via [`OnceCell`] (async-aware). /// /// Auth is resolved per-client: -/// - SSO bearer (`get_token()`) — admin APIs, Matrix, La Suite, OpenSearch +/// - SSO bearer (`get_token()`) — admin APIs, Matrix, OpenSearch /// - Gitea PAT (`get_gitea_token()`) — Gitea /// - None — Prometheus, Loki, S3, LiveKit pub struct SunbeamClient { @@ -260,20 +260,6 @@ pub struct SunbeamClient { loki: OnceCell, #[cfg(feature = "monitoring")] grafana: OnceCell, - #[cfg(feature = "lasuite")] - people: OnceCell, - #[cfg(feature = "lasuite")] - docs: OnceCell, - #[cfg(feature = "lasuite")] - meet: OnceCell, - #[cfg(feature = "lasuite")] - drive: OnceCell, - #[cfg(feature = "lasuite")] - messages: OnceCell, - #[cfg(feature = "lasuite")] - calendars: OnceCell, - #[cfg(feature = "lasuite")] - find: OnceCell, bao: OnceCell, } @@ -303,20 +289,6 @@ impl SunbeamClient { loki: OnceCell::new(), #[cfg(feature = "monitoring")] grafana: OnceCell::new(), - #[cfg(feature = "lasuite")] - people: OnceCell::new(), - #[cfg(feature = "lasuite")] - docs: OnceCell::new(), - #[cfg(feature = "lasuite")] - meet: OnceCell::new(), - #[cfg(feature = "lasuite")] - drive: OnceCell::new(), - #[cfg(feature = "lasuite")] - messages: OnceCell::new(), - #[cfg(feature = "lasuite")] - calendars: OnceCell::new(), - #[cfg(feature = "lasuite")] - find: OnceCell::new(), bao: OnceCell::new(), } } @@ -433,70 +405,6 @@ impl SunbeamClient { }).await } - #[cfg(feature = "lasuite")] - pub async fn people(&self) -> Result<&crate::lasuite::PeopleClient> { - // Ensure we have a valid token (triggers refresh if expired). - self.sso_token().await?; - self.people.get_or_try_init(|| async { - let url = format!("https://people.{}/external_api/v1.0", self.domain); - Ok(crate::lasuite::PeopleClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - - #[cfg(feature = "lasuite")] - pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> { - self.sso_token().await?; - self.docs.get_or_try_init(|| async { - let url = format!("https://docs.{}/external_api/v1.0", self.domain); - Ok(crate::lasuite::DocsClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - - #[cfg(feature = "lasuite")] - pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> { - self.sso_token().await?; - self.meet.get_or_try_init(|| async { - let url = format!("https://meet.{}/external-api/v1.0", self.domain); - Ok(crate::lasuite::MeetClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - - #[cfg(feature = "lasuite")] - pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> { - self.sso_token().await?; - self.drive.get_or_try_init(|| async { - let url = format!("https://drive.{}/external_api/v1.0", self.domain); - Ok(crate::lasuite::DriveClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - - #[cfg(feature = "lasuite")] - pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> { - self.sso_token().await?; - self.messages.get_or_try_init(|| async { - let url = format!("https://mail.{}/external_api/v1.0", self.domain); - Ok(crate::lasuite::MessagesClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - - #[cfg(feature = "lasuite")] - pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> { - self.sso_token().await?; - self.calendars.get_or_try_init(|| async { - let url = format!("https://calendar.{}/external_api/v1.0", self.domain); - Ok(crate::lasuite::CalendarsClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - - #[cfg(feature = "lasuite")] - pub async fn find(&self) -> Result<&crate::lasuite::FindClient> { - self.sso_token().await?; - self.find.get_or_try_init(|| async { - let url = format!("https://find.{}/external_api/v1.0", self.domain); - Ok(crate::lasuite::FindClient::from_parts(url, AuthMethod::DynamicBearer)) - }).await - } - pub async fn bao(&self) -> Result<&crate::openbao::BaoClient> { self.bao.get_or_try_init(|| async { let url = format!("https://vault.{}", self.domain); diff --git a/sunbeam-sdk/src/constants.rs b/sunbeam-sdk/src/constants.rs index 5ab992e0..ec7686f3 100644 --- a/sunbeam-sdk/src/constants.rs +++ b/sunbeam-sdk/src/constants.rs @@ -2,15 +2,18 @@ pub const GITEA_ADMIN_USER: &str = "gitea_admin"; +/// Deprecated: prefer `registry::discover()` → `ServiceRegistry::namespaces()`. pub const MANAGED_NS: &[&str] = &[ "data", "devtools", "ingress", - "lasuite", "matrix", "media", "monitoring", "ory", + "stalwart", "storage", "vault-secrets-operator", + "vpn", + "wfe", ]; diff --git a/sunbeam-sdk/src/gitea/mod.rs b/sunbeam-sdk/src/gitea/mod.rs index 3ce0a7fb..65cc43eb 100644 --- a/sunbeam-sdk/src/gitea/mod.rs +++ b/sunbeam-sdk/src/gitea/mod.rs @@ -874,8 +874,8 @@ async fn configure_oidc(pod: &str, _password: &str) -> Result<()> { } // Create new OIDC auth source - let oidc_id = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await; - let oidc_secret = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await; + let oidc_id = kube_get_secret_field("devtools", "oidc-gitea", "CLIENT_ID").await; + let oidc_secret = kube_get_secret_field("devtools", "oidc-gitea", "CLIENT_SECRET").await; match (oidc_id, oidc_secret) { (Ok(oidc_id), Ok(oidc_sec)) => { diff --git a/sunbeam-sdk/src/images/builders.rs b/sunbeam-sdk/src/images/builders.rs index a0bd9c92..e45a0f85 100644 --- a/sunbeam-sdk/src/images/builders.rs +++ b/sunbeam-sdk/src/images/builders.rs @@ -1,52 +1,10 @@ //! Per-service image build functions. -use crate::error::{Result, ResultExt, SunbeamError}; -use crate::output::{ok, step, warn}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use crate::error::{Result, SunbeamError}; +use crate::output::step; use super::{build_image, deploy_rollout, get_build_env}; -/// Message component definition: (cli_name, image_name, dockerfile_rel, target). -pub const MESSAGES_COMPONENTS: &[(&str, &str, &str, Option<&str>)] = &[ - ( - "messages-backend", - "messages-backend", - "src/backend/Dockerfile", - Some("runtime-distroless-prod"), - ), - ( - "messages-frontend", - "messages-frontend", - "src/frontend/Dockerfile", - Some("runtime-prod"), - ), - ( - "messages-mta-in", - "messages-mta-in", - "src/mta-in/Dockerfile", - None, - ), - ( - "messages-mta-out", - "messages-mta-out", - "src/mta-out/Dockerfile", - None, - ), - ( - "messages-mpa", - "messages-mpa", - "src/mpa/rspamd/Dockerfile", - None, - ), - ( - "messages-socks-proxy", - "messages-socks-proxy", - "src/socks-proxy/Dockerfile", - None, - ), -]; - pub async fn build_proxy(push: bool, deploy: bool, no_cache: bool) -> Result<()> { let env = get_build_env().await?; let proxy_dir = crate::config::get_repo_root().join("proxy"); @@ -105,68 +63,6 @@ pub async fn build_tuwunel(push: bool, deploy: bool, no_cache: bool) -> Result<( Ok(()) } -pub async fn build_integration(push: bool, deploy: bool, no_cache: bool) -> Result<()> { - let env = get_build_env().await?; - let sunbeam_dir = crate::config::get_repo_root(); - let integration_service_dir = sunbeam_dir.join("integration-service"); - let dockerfile = integration_service_dir.join("Dockerfile"); - let dockerignore = integration_service_dir.join(".dockerignore"); - - if !dockerfile.exists() { - return Err(SunbeamError::build(format!( - "integration-service Dockerfile not found at {}", - dockerfile.display() - ))); - } - if !sunbeam_dir - .join("integration") - .join("packages") - .join("widgets") - .is_dir() - { - 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); - step(&format!("Building integration -> {image} ...")); - - // .dockerignore needs to be at context root - let root_ignore = sunbeam_dir.join(".dockerignore"); - let mut copied_ignore = false; - if !root_ignore.exists() && dockerignore.exists() { - std::fs::copy(&dockerignore, &root_ignore).ok(); - copied_ignore = true; - } - - let result = build_image( - &env, - &image, - &dockerfile, - &sunbeam_dir, - None, - None, - push, - no_cache, - &[], - ) - .await; - - if copied_ignore && root_ignore.exists() { - let _ = std::fs::remove_file(&root_ignore); - } - - result?; - - if deploy { - deploy_rollout(&env, &["integration"], "lasuite", 120, None).await?; - } - Ok(()) -} - pub async fn build_kratos_admin(push: bool, deploy: bool, no_cache: bool) -> Result<()> { let env = get_build_env().await?; let kratos_admin_dir = crate::config::get_repo_root().join("kratos-admin"); @@ -199,455 +95,8 @@ pub async fn build_kratos_admin(push: bool, deploy: bool, no_cache: bool) -> Res Ok(()) } -pub async fn build_meet(push: bool, deploy: bool, no_cache: bool) -> Result<()> { - let env = get_build_env().await?; - let meet_dir = crate::config::get_repo_root().join("meet"); - if !meet_dir.is_dir() { - return Err(SunbeamError::build(format!("meet source not found at {}", meet_dir.display()))); - } - - let backend_image = format!("{}/studio/meet-backend:latest", env.registry); - let frontend_image = format!("{}/studio/meet-frontend:latest", env.registry); - - // Backend - step(&format!("Building meet-backend -> {backend_image} ...")); - build_image( - &env, - &backend_image, - &meet_dir.join("Dockerfile"), - &meet_dir, - Some("backend-production"), - None, - push, - no_cache, - &[], - ) - .await?; - - // Frontend - step(&format!("Building meet-frontend -> {frontend_image} ...")); - let frontend_dockerfile = meet_dir.join("src").join("frontend").join("Dockerfile"); - if !frontend_dockerfile.exists() { - return Err(SunbeamError::build(format!( - "meet frontend Dockerfile not found at {}", - frontend_dockerfile.display() - ))); - } - - let mut build_args = HashMap::new(); - build_args.insert("VITE_API_BASE_URL".to_string(), String::new()); - - build_image( - &env, - &frontend_image, - &frontend_dockerfile, - &meet_dir, - Some("frontend-production"), - Some(&build_args), - push, - no_cache, - &[], - ) - .await?; - - if deploy { - deploy_rollout( - &env, - &["meet-backend", "meet-celery-worker", "meet-frontend"], - "lasuite", - 180, - None, - ) - .await?; - } - Ok(()) -} - -pub async fn build_people(push: bool, deploy: bool, no_cache: bool) -> Result<()> { - let env = get_build_env().await?; - let people_dir = crate::config::get_repo_root().join("people"); - if !people_dir.is_dir() { - 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() { - return Err(SunbeamError::build(format!("Dockerfile not found at {}", dockerfile.display()))); - } - - let image = format!("{}/studio/people-frontend:latest", env.registry); - step(&format!("Building people-frontend -> {image} ...")); - - // yarn install - ok("Updating yarn.lock (yarn install in workspace)..."); - let yarn_status = tokio::process::Command::new("yarn") - .args(["install", "--ignore-engines"]) - .current_dir(&workspace_dir) - .status() - .await - .ctx("Failed to run yarn install")?; - if !yarn_status.success() { - return Err(SunbeamError::tool("yarn", "install failed")); - } - - // cunningham design tokens - ok("Regenerating cunningham design tokens..."); - let cunningham_bin = workspace_dir - .join("node_modules") - .join(".bin") - .join("cunningham"); - let cunningham_status = tokio::process::Command::new(&cunningham_bin) - .args(["-g", "css,ts", "-o", "src/cunningham", "--utility-classes"]) - .current_dir(&app_dir) - .status() - .await - .ctx("Failed to run cunningham")?; - if !cunningham_status.success() { - return Err(SunbeamError::tool("cunningham", "design token generation failed")); - } - - let mut build_args = HashMap::new(); - build_args.insert("DOCKER_USER".to_string(), "101".to_string()); - - build_image( - &env, - &image, - &dockerfile, - &people_dir, - Some("frontend-production"), - Some(&build_args), - push, - no_cache, - &[], - ) - .await?; - - if deploy { - deploy_rollout(&env, &["people-frontend"], "lasuite", 180, None).await?; - } - Ok(()) -} - -pub async fn build_messages(what: &str, push: bool, deploy: bool, no_cache: bool) -> Result<()> { - let env = get_build_env().await?; - let messages_dir = crate::config::get_repo_root().join("messages"); - if !messages_dir.is_dir() { - return Err(SunbeamError::build(format!("messages source not found at {}", messages_dir.display()))); - } - - let components: Vec<_> = if what == "messages" { - MESSAGES_COMPONENTS.to_vec() - } else { - MESSAGES_COMPONENTS - .iter() - .filter(|(name, _, _, _)| *name == what) - .copied() - .collect() - }; - - let mut built_images = Vec::new(); - - for (component, image_name, dockerfile_rel, target) in &components { - let dockerfile = messages_dir.join(dockerfile_rel); - if !dockerfile.exists() { - warn(&format!( - "Dockerfile not found at {} -- skipping {component}", - dockerfile.display() - )); - continue; - } - - let image = format!("{}/studio/{image_name}:latest", env.registry); - let context_dir = dockerfile.parent().unwrap_or(&messages_dir); - step(&format!("Building {component} -> {image} ...")); - - // Patch ghcr.io/astral-sh/uv COPY for messages-backend on local builds - let mut cleanup_paths = Vec::new(); - let actual_dockerfile; - - if !env.is_prod && *image_name == "messages-backend" { - let (patched, cleanup) = - patch_dockerfile_uv(&dockerfile, context_dir, &env.platform).await?; - actual_dockerfile = patched; - cleanup_paths = cleanup; - } else { - actual_dockerfile = dockerfile.clone(); - } - - build_image( - &env, - &image, - &actual_dockerfile, - context_dir, - *target, - None, - push, - no_cache, - &cleanup_paths, - ) - .await?; - - built_images.push(image); - } - - if deploy && !built_images.is_empty() { - deploy_rollout( - &env, - &[ - "messages-backend", - "messages-worker", - "messages-frontend", - "messages-mta-in", - "messages-mta-out", - "messages-mpa", - "messages-socks-proxy", - ], - "lasuite", - 180, - None, - ) - .await?; - } - - Ok(()) -} - -/// Build a La Suite frontend image from source and push to the Gitea registry. -#[allow(clippy::too_many_arguments)] -pub async fn build_la_suite_frontend( - app: &str, - repo_dir: &Path, - workspace_rel: &str, - app_rel: &str, - dockerfile_rel: &str, - image_name: &str, - deployment: &str, - namespace: &str, - push: bool, - deploy: bool, - no_cache: bool, -) -> Result<()> { - let env = get_build_env().await?; - - let workspace_dir = repo_dir.join(workspace_rel); - let app_dir = repo_dir.join(app_rel); - let dockerfile = repo_dir.join(dockerfile_rel); - - if !repo_dir.is_dir() { - return Err(SunbeamError::build(format!("{app} source not found at {}", repo_dir.display()))); - } - if !dockerfile.exists() { - return Err(SunbeamError::build(format!("Dockerfile not found at {}", dockerfile.display()))); - } - - let image = format!("{}/studio/{image_name}:latest", env.registry); - step(&format!("Building {app} -> {image} ...")); - - ok("Updating yarn.lock (yarn install in workspace)..."); - let yarn_status = tokio::process::Command::new("yarn") - .args(["install", "--ignore-engines"]) - .current_dir(&workspace_dir) - .status() - .await - .ctx("Failed to run yarn install")?; - if !yarn_status.success() { - return Err(SunbeamError::tool("yarn", "install failed")); - } - - ok("Regenerating cunningham design tokens (yarn build-theme)..."); - let theme_status = tokio::process::Command::new("yarn") - .args(["build-theme"]) - .current_dir(&app_dir) - .status() - .await - .ctx("Failed to run yarn build-theme")?; - if !theme_status.success() { - return Err(SunbeamError::tool("yarn", "build-theme failed")); - } - - let mut build_args = HashMap::new(); - build_args.insert("DOCKER_USER".to_string(), "101".to_string()); - - build_image( - &env, - &image, - &dockerfile, - repo_dir, - Some("frontend-production"), - Some(&build_args), - push, - no_cache, - &[], - ) - .await?; - - if deploy { - deploy_rollout(&env, &[deployment], namespace, 180, None).await?; - } - Ok(()) -} - -/// Download uv from GitHub releases and return a patched Dockerfile path. -pub async fn patch_dockerfile_uv( - dockerfile_path: &Path, - context_dir: &Path, - platform: &str, -) -> Result<(PathBuf, Vec)> { - let content = std::fs::read_to_string(dockerfile_path) - .ctx("Failed to read Dockerfile for uv patching")?; - - // Match COPY --from=ghcr.io/astral-sh/uv@sha256:... /uv /uvx /bin/ - let original_copy = content - .lines() - .find(|line| { - line.contains("COPY") - && line.contains("--from=ghcr.io/astral-sh/uv@sha256:") - && line.contains("/uv") - && line.contains("/bin/") - }) - .map(|line| line.trim().to_string()); - - let original_copy = match original_copy { - Some(c) => c, - None => return Ok((dockerfile_path.to_path_buf(), vec![])), - }; - - // Find uv version from comment like: oci://ghcr.io/astral-sh/uv:0.x.y - let version = content - .lines() - .find_map(|line| { - let marker = "oci://ghcr.io/astral-sh/uv:"; - if let Some(idx) = line.find(marker) { - let rest = &line[idx + marker.len()..]; - let ver = rest.split_whitespace().next().unwrap_or(""); - if !ver.is_empty() { - Some(ver.to_string()) - } else { - None - } - } else { - None - } - }); - - let version = match version { - Some(v) => v, - None => { - warn("Could not find uv version comment in Dockerfile; ghcr.io pull may fail."); - return Ok((dockerfile_path.to_path_buf(), vec![])); - } - }; - - let arch = if platform.contains("amd64") { - "x86_64" - } else { - "aarch64" - }; - - let url = format!( - "https://github.com/astral-sh/uv/releases/download/{version}/uv-{arch}-unknown-linux-gnu.tar.gz" - ); - - let stage_dir = context_dir.join("_sunbeam_uv_stage"); - let patched_df = dockerfile_path - .parent() - .unwrap_or(dockerfile_path) - .join("Dockerfile._sunbeam_patched"); - let cleanup = vec![stage_dir.clone(), patched_df.clone()]; - - ok(&format!( - "Downloading uv {version} ({arch}) from GitHub releases to bypass ghcr.io..." - )); - - std::fs::create_dir_all(&stage_dir)?; - - // Download tarball - let response = reqwest::get(&url) - .await - .ctx("Failed to download uv release")?; - let tarball_bytes = response.bytes().await?; - - // Extract uv and uvx from tarball - let decoder = flate2::read::GzDecoder::new(&tarball_bytes[..]); - let mut archive = tar::Archive::new(decoder); - - for entry in archive.entries()? { - let mut entry = entry?; - let path = entry.path()?.to_path_buf(); - let file_name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - if (file_name == "uv" || file_name == "uvx") && entry.header().entry_type().is_file() { - let dest = stage_dir.join(&file_name); - let mut outfile = std::fs::File::create(&dest)?; - std::io::copy(&mut entry, &mut outfile)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?; - } - } - } - - if !stage_dir.join("uv").exists() { - warn("uv binary not found in release tarball; build may fail."); - return Ok((dockerfile_path.to_path_buf(), cleanup)); - } - - let patched = content.replace( - &original_copy, - "COPY _sunbeam_uv_stage/uv _sunbeam_uv_stage/uvx /bin/", - ); - std::fs::write(&patched_df, patched)?; - ok(&format!(" uv {version} staged; using patched Dockerfile.")); - - Ok((patched_df, cleanup)) -} - -pub async fn build_projects(push: bool, deploy: bool, no_cache: bool) -> Result<()> { - let env = get_build_env().await?; - let projects_dir = crate::config::get_repo_root().join("projects"); - if !projects_dir.is_dir() { - return Err(SunbeamError::build(format!("projects source not found at {}", projects_dir.display()))); - } - - let image = format!("{}/studio/projects:latest", env.registry); - step(&format!("Building projects -> {image} ...")); - - build_image( - &env, - &image, - &projects_dir.join("Dockerfile"), - &projects_dir, - None, - None, - push, - no_cache, - &[], - ) - .await?; - - if deploy { - deploy_rollout(&env, &["projects"], "lasuite", 180, Some(&[image])).await?; - } - Ok(()) -} - // TODO: first deploy requires registration enabled on tuwunel to create -// the @sol:sunbeam.pt bot account. Flow: -// 1. Set allow_registration = true in tuwunel-config.yaml -// 2. Apply + restart tuwunel -// 3. Register bot via POST /_matrix/client/v3/register with registration token -// 4. Store access_token + device_id in OpenBao at secret/sol -// 5. Set allow_registration = false, re-apply -// 6. Then build + deploy sol -// This should be automated as `sunbeam user create-bot `. +// the @sol:sunbeam.pt bot account. pub async fn build_sol(push: bool, deploy: bool, no_cache: bool) -> Result<()> { let env = get_build_env().await?; let sol_dir = crate::config::get_repo_root().join("sol"); @@ -677,130 +126,3 @@ pub async fn build_sol(push: bool, deploy: bool, no_cache: bool) -> Result<()> { Ok(()) } -pub async fn build_calendars(push: bool, deploy: bool, no_cache: bool) -> Result<()> { - let env = get_build_env().await?; - let cal_dir = crate::config::get_repo_root().join("calendars"); - if !cal_dir.is_dir() { - return Err(SunbeamError::build(format!("calendars source not found at {}", cal_dir.display()))); - } - - let backend_dir = cal_dir.join("src").join("backend"); - let backend_image = format!("{}/studio/calendars-backend:latest", env.registry); - step(&format!("Building calendars-backend -> {backend_image} ...")); - - // Stage translations.json into the build context - let translations_src = cal_dir - .join("src") - .join("frontend") - .join("apps") - .join("calendars") - .join("src") - .join("features") - .join("i18n") - .join("translations.json"); - - let translations_dst = backend_dir.join("_translations.json"); - let mut cleanup: Vec = Vec::new(); - let mut dockerfile = backend_dir.join("Dockerfile"); - - if translations_src.exists() { - std::fs::copy(&translations_src, &translations_dst)?; - cleanup.push(translations_dst); - - // Patch Dockerfile to COPY translations into production image - let mut content = std::fs::read_to_string(&dockerfile)?; - content.push_str( - "\n# Sunbeam: bake translations.json for default calendar names\n\ - COPY _translations.json /data/translations.json\n", - ); - let patched_df = backend_dir.join("Dockerfile._sunbeam_patched"); - std::fs::write(&patched_df, content)?; - cleanup.push(patched_df.clone()); - dockerfile = patched_df; - } - - build_image( - &env, - &backend_image, - &dockerfile, - &backend_dir, - Some("backend-production"), - None, - push, - no_cache, - &cleanup, - ) - .await?; - - // caldav - let caldav_image = format!("{}/studio/calendars-caldav:latest", env.registry); - step(&format!("Building calendars-caldav -> {caldav_image} ...")); - let caldav_dir = cal_dir.join("src").join("caldav"); - build_image( - &env, - &caldav_image, - &caldav_dir.join("Dockerfile"), - &caldav_dir, - None, - None, - push, - no_cache, - &[], - ) - .await?; - - // frontend - let frontend_image = format!("{}/studio/calendars-frontend:latest", env.registry); - step(&format!( - "Building calendars-frontend -> {frontend_image} ..." - )); - let integration_base = format!("https://integration.{}", env.domain); - let mut build_args = HashMap::new(); - build_args.insert( - "VISIO_BASE_URL".to_string(), - format!("https://meet.{}", env.domain), - ); - build_args.insert( - "GAUFRE_WIDGET_PATH".to_string(), - format!("{integration_base}/api/v2/lagaufre.js"), - ); - build_args.insert( - "GAUFRE_API_URL".to_string(), - format!("{integration_base}/api/v2/services.json"), - ); - build_args.insert( - "THEME_CSS_URL".to_string(), - format!("{integration_base}/api/v2/theme.css"), - ); - - let frontend_dir = cal_dir.join("src").join("frontend"); - build_image( - &env, - &frontend_image, - &frontend_dir.join("Dockerfile"), - &frontend_dir, - Some("frontend-production"), - Some(&build_args), - push, - no_cache, - &[], - ) - .await?; - - if deploy { - deploy_rollout( - &env, - &[ - "calendars-backend", - "calendars-worker", - "calendars-caldav", - "calendars-frontend", - ], - "lasuite", - 180, - Some(&[backend_image, caldav_image, frontend_image]), - ) - .await?; - } - Ok(()) -} diff --git a/sunbeam-sdk/src/images/mod.rs b/sunbeam-sdk/src/images/mod.rs index b71f65d0..4714dd7a 100644 --- a/sunbeam-sdk/src/images/mod.rs +++ b/sunbeam-sdk/src/images/mod.rs @@ -19,22 +19,8 @@ use crate::output::{ok, step, warn}; #[cfg_attr(feature = "cli", derive(clap::ValueEnum))] pub enum BuildTarget { Proxy, - Integration, KratosAdmin, - Meet, - DocsFrontend, - PeopleFrontend, - People, - Messages, - MessagesBackend, - MessagesFrontend, - MessagesMtaIn, - MessagesMtaOut, - MessagesMpa, - MessagesSocksProxy, Tuwunel, - Calendars, - Projects, Sol, } @@ -42,22 +28,8 @@ impl std::fmt::Display for BuildTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { BuildTarget::Proxy => "proxy", - BuildTarget::Integration => "integration", BuildTarget::KratosAdmin => "kratos-admin", - BuildTarget::Meet => "meet", - BuildTarget::DocsFrontend => "docs-frontend", - BuildTarget::PeopleFrontend => "people-frontend", - BuildTarget::People => "people", - BuildTarget::Messages => "messages", - BuildTarget::MessagesBackend => "messages-backend", - BuildTarget::MessagesFrontend => "messages-frontend", - BuildTarget::MessagesMtaIn => "messages-mta-in", - BuildTarget::MessagesMtaOut => "messages-mta-out", - BuildTarget::MessagesMpa => "messages-mpa", - BuildTarget::MessagesSocksProxy => "messages-socks-proxy", BuildTarget::Tuwunel => "tuwunel", - BuildTarget::Calendars => "calendars", - BuildTarget::Projects => "projects", BuildTarget::Sol => "sol", }; write!(f, "{s}") @@ -65,38 +37,7 @@ impl std::fmt::Display for BuildTarget { } /// amd64-only images that need mirroring: (source, org, repo, tag). -const AMD64_ONLY_IMAGES: &[(&str, &str, &str, &str)] = &[ - ( - "docker.io/lasuite/people-backend:latest", - "studio", - "people-backend", - "latest", - ), - ( - "docker.io/lasuite/people-frontend:latest", - "studio", - "people-frontend", - "latest", - ), - ( - "docker.io/lasuite/impress-backend:latest", - "studio", - "impress-backend", - "latest", - ), - ( - "docker.io/lasuite/impress-frontend:latest", - "studio", - "impress-frontend", - "latest", - ), - ( - "docker.io/lasuite/impress-y-provider:latest", - "studio", - "impress-y-provider", - "latest", - ), -]; +const AMD64_ONLY_IMAGES: &[(&str, &str, &str, &str)] = &[]; // --------------------------------------------------------------------------- // Build environment @@ -895,39 +836,8 @@ async fn clear_image_pull_error_pods() -> Result<()> { pub async fn cmd_build(what: &BuildTarget, push: bool, deploy: bool, no_cache: bool) -> Result<()> { match what { BuildTarget::Proxy => builders::build_proxy(push, deploy, no_cache).await, - BuildTarget::Integration => builders::build_integration(push, deploy, no_cache).await, BuildTarget::KratosAdmin => builders::build_kratos_admin(push, deploy, no_cache).await, - BuildTarget::Meet => builders::build_meet(push, deploy, no_cache).await, - BuildTarget::DocsFrontend => { - let repo_dir = crate::config::get_repo_root().join("docs"); - builders::build_la_suite_frontend( - "docs-frontend", - &repo_dir, - "src/frontend", - "src/frontend/apps/impress", - "src/frontend/Dockerfile", - "impress-frontend", - "docs-frontend", - "lasuite", - push, - deploy, - no_cache, - ) - .await - } - BuildTarget::PeopleFrontend | BuildTarget::People => builders::build_people(push, deploy, no_cache).await, - BuildTarget::Messages => builders::build_messages("messages", push, deploy, no_cache).await, - BuildTarget::MessagesBackend => builders::build_messages("messages-backend", push, deploy, no_cache).await, - BuildTarget::MessagesFrontend => builders::build_messages("messages-frontend", push, deploy, no_cache).await, - BuildTarget::MessagesMtaIn => builders::build_messages("messages-mta-in", push, deploy, no_cache).await, - BuildTarget::MessagesMtaOut => builders::build_messages("messages-mta-out", push, deploy, no_cache).await, - BuildTarget::MessagesMpa => builders::build_messages("messages-mpa", push, deploy, no_cache).await, - BuildTarget::MessagesSocksProxy => { - builders::build_messages("messages-socks-proxy", push, deploy, no_cache).await - } BuildTarget::Tuwunel => builders::build_tuwunel(push, deploy, no_cache).await, - BuildTarget::Calendars => builders::build_calendars(push, deploy, no_cache).await, - BuildTarget::Projects => builders::build_projects(push, deploy, no_cache).await, BuildTarget::Sol => builders::build_sol(push, deploy, no_cache).await, } } @@ -956,41 +866,8 @@ mod tests { } #[test] - fn amd64_only_images_all_from_docker_hub() { - for (src, _org, _repo, _tag) in AMD64_ONLY_IMAGES { - assert!( - src.starts_with("docker.io/"), - "Expected docker.io prefix, got: {src}" - ); - } - } - - #[test] - fn amd64_only_images_all_have_latest_tag() { - for (src, _org, _repo, tag) in AMD64_ONLY_IMAGES { - assert_eq!( - *tag, "latest", - "Expected 'latest' tag for {src}, got: {tag}" - ); - } - } - - #[test] - fn amd64_only_images_non_empty() { - assert!( - !AMD64_ONLY_IMAGES.is_empty(), - "AMD64_ONLY_IMAGES should not be empty" - ); - } - - #[test] - fn amd64_only_images_org_is_studio() { - for (src, org, _repo, _tag) in AMD64_ONLY_IMAGES { - assert_eq!( - *org, "studio", - "Expected org 'studio' for {src}, got: {org}" - ); - } + fn amd64_only_images_empty_after_lasuite_removal() { + assert!(AMD64_ONLY_IMAGES.is_empty()); } #[test] @@ -1007,22 +884,8 @@ mod tests { fn build_target_display_all_lowercase_or_hyphenated() { let targets = [ BuildTarget::Proxy, - BuildTarget::Integration, BuildTarget::KratosAdmin, - BuildTarget::Meet, - BuildTarget::DocsFrontend, - BuildTarget::PeopleFrontend, - BuildTarget::People, - BuildTarget::Messages, - BuildTarget::MessagesBackend, - BuildTarget::MessagesFrontend, - BuildTarget::MessagesMtaIn, - BuildTarget::MessagesMtaOut, - BuildTarget::MessagesMpa, - BuildTarget::MessagesSocksProxy, BuildTarget::Tuwunel, - BuildTarget::Calendars, - BuildTarget::Projects, BuildTarget::Sol, ]; for t in &targets { @@ -1039,32 +902,4 @@ mod tests { assert_eq!(GITEA_ADMIN_USER, "gitea_admin"); } - #[test] - fn messages_components_non_empty() { - assert!(!builders::MESSAGES_COMPONENTS.is_empty()); - } - - #[test] - fn messages_components_dockerfiles_are_relative() { - for (_name, _image, dockerfile_rel, _target) in builders::MESSAGES_COMPONENTS { - assert!( - dockerfile_rel.ends_with("Dockerfile"), - "Expected Dockerfile suffix in: {dockerfile_rel}" - ); - assert!( - !dockerfile_rel.starts_with('/'), - "Dockerfile path should be relative: {dockerfile_rel}" - ); - } - } - - #[test] - fn messages_components_names_match_build_targets() { - for (name, _image, _df, _target) in builders::MESSAGES_COMPONENTS { - assert!( - name.starts_with("messages-"), - "Component name should start with 'messages-': {name}" - ); - } - } } diff --git a/sunbeam-sdk/src/kube/mod.rs b/sunbeam-sdk/src/kube/mod.rs index c9f4405a..7d4c2e73 100644 --- a/sunbeam-sdk/src/kube/mod.rs +++ b/sunbeam-sdk/src/kube/mod.rs @@ -409,7 +409,7 @@ pub async fn kube_rollout_restart(ns: &str, deployment: &str) -> Result<()> { /// Discover the active domain from cluster state. /// /// Tries the gitea-inline-config secret first (DOMAIN=src.), -/// falls back to lasuite-oidc-provider configmap, then Lima VM IP. +/// then falls back to the Lima VM IP for local development. #[allow(dead_code)] pub async fn get_domain() -> Result { // 1. Gitea inline-config secret @@ -426,25 +426,7 @@ pub async fn get_domain() -> Result { } } - // 2. Fallback: lasuite-oidc-provider configmap - { - let client = get_client().await?; - let api: Api = - Api::namespaced(client.clone(), "lasuite"); - if let Ok(Some(cm)) = api.get_opt("lasuite-oidc-provider").await { - if let Some(data) = &cm.data { - if let Some(endpoint) = data.get("OIDC_OP_JWKS_ENDPOINT") { - if let Some(rest) = endpoint.split("https://auth.").nth(1) { - if let Some(domain) = rest.split('/').next() { - return Ok(domain.to_string()); - } - } - } - } - } - } - - // 3. Local dev fallback: Lima VM IP + // 2. Local dev fallback: Lima VM IP let ip = get_lima_ip().await; Ok(format!("{ip}.sslip.io")) } diff --git a/sunbeam-sdk/src/lasuite/calendars.rs b/sunbeam-sdk/src/lasuite/calendars.rs deleted file mode 100644 index 36aff699..00000000 --- a/sunbeam-sdk/src/lasuite/calendars.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Calendars service client — calendars, events, RSVP. - -use crate::client::{AuthMethod, HttpTransport, ServiceClient}; -use crate::error::Result; -use reqwest::Method; -use super::types::*; - -/// Client for the La Suite Calendars API. -pub struct CalendarsClient { - pub(crate) transport: HttpTransport, -} - -impl ServiceClient for CalendarsClient { - fn service_name(&self) -> &'static str { - "calendars" - } - - fn base_url(&self) -> &str { - &self.transport.base_url - } - - fn from_parts(base_url: String, auth: AuthMethod) -> Self { - Self { - transport: HttpTransport::new(&base_url, auth), - } - } -} - -impl CalendarsClient { - /// Build a CalendarsClient from domain (e.g. `https://calendar.{domain}/api/v1.0`). - pub fn connect(domain: &str) -> Self { - let base_url = format!("https://calendar.{domain}/api/v1.0"); - Self::from_parts(base_url, AuthMethod::Bearer(String::new())) - } - - /// Set the bearer token for authentication. - pub fn with_token(mut self, token: &str) -> Self { - self.transport.set_auth(AuthMethod::Bearer(token.to_string())); - self - } - - // -- Calendars ---------------------------------------------------------- - - /// List calendars. - pub async fn list_calendars(&self) -> Result> { - self.transport - .json( - Method::GET, - "calendars/", - Option::<&()>::None, - "calendars list calendars", - ) - .await - } - - /// Get a single calendar by ID. - pub async fn get_calendar(&self, id: &str) -> Result { - self.transport - .json( - Method::GET, - &format!("calendars/{id}/"), - Option::<&()>::None, - "calendars get calendar", - ) - .await - } - - /// Create a new calendar. - pub async fn create_calendar(&self, body: &serde_json::Value) -> Result { - self.transport - .json(Method::POST, "calendars/", Some(body), "calendars create calendar") - .await - } - - // -- Events ------------------------------------------------------------- - - /// List events in a calendar. - pub async fn list_events(&self, calendar_id: &str) -> Result> { - self.transport - .json( - Method::GET, - &format!("calendars/{calendar_id}/events/"), - Option::<&()>::None, - "calendars list events", - ) - .await - } - - /// Get a single event. - pub async fn get_event( - &self, - calendar_id: &str, - event_id: &str, - ) -> Result { - self.transport - .json( - Method::GET, - &format!("calendars/{calendar_id}/events/{event_id}/"), - Option::<&()>::None, - "calendars get event", - ) - .await - } - - /// Create a new event in a calendar. - pub async fn create_event( - &self, - calendar_id: &str, - body: &serde_json::Value, - ) -> Result { - self.transport - .json( - Method::POST, - &format!("calendars/{calendar_id}/events/"), - Some(body), - "calendars create event", - ) - .await - } - - /// Update an event (partial). - pub async fn update_event( - &self, - calendar_id: &str, - event_id: &str, - body: &serde_json::Value, - ) -> Result { - self.transport - .json( - Method::PATCH, - &format!("calendars/{calendar_id}/events/{event_id}/"), - Some(body), - "calendars update event", - ) - .await - } - - /// Delete an event. - pub async fn delete_event( - &self, - calendar_id: &str, - event_id: &str, - ) -> Result<()> { - self.transport - .send( - Method::DELETE, - &format!("calendars/{calendar_id}/events/{event_id}/"), - Option::<&()>::None, - "calendars delete event", - ) - .await - } - - /// RSVP to an event. - pub async fn rsvp( - &self, - calendar_id: &str, - event_id: &str, - body: &serde_json::Value, - ) -> Result<()> { - self.transport - .send( - Method::POST, - &format!("calendars/{calendar_id}/events/{event_id}/rsvp/"), - Some(body), - "calendars rsvp", - ) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_connect_url() { - let c = CalendarsClient::connect("sunbeam.pt"); - assert_eq!(c.base_url(), "https://calendar.sunbeam.pt/api/v1.0"); - assert_eq!(c.service_name(), "calendars"); - } - - #[test] - fn test_from_parts() { - let c = CalendarsClient::from_parts( - "http://localhost:8000/api/v1.0".into(), - AuthMethod::Bearer("tok".into()), - ); - assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0"); - } -} diff --git a/sunbeam-sdk/src/lasuite/cli.rs b/sunbeam-sdk/src/lasuite/cli.rs deleted file mode 100644 index 191abbd1..00000000 --- a/sunbeam-sdk/src/lasuite/cli.rs +++ /dev/null @@ -1,1512 +0,0 @@ -//! CLI command definitions and dispatch for all 7 La Suite services. - -use clap::Subcommand; - -use crate::client::SunbeamClient; -use crate::error::Result; -use crate::output::{self, OutputFormat}; - - -// ═══════════════════════════════════════════════════════════════════════════ -// People -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum PeopleCommand { - /// Contact management. - Contact { - #[command(subcommand)] - action: ContactAction, - }, - /// Team management. - Team { - #[command(subcommand)] - action: TeamAction, - }, - /// Service provider listing. - ServiceProvider { - #[command(subcommand)] - action: ServiceProviderAction, - }, - /// Mail domain listing. - MailDomain { - #[command(subcommand)] - action: MailDomainAction, - }, -} - -#[derive(Subcommand, Debug)] -pub enum ContactAction { - /// List contacts. - List { - #[arg(long)] - page: Option, - }, - /// Get a contact by ID. - Get { - #[arg(short, long)] - id: String, - }, - /// Create a contact from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Update a contact from JSON. - Update { - #[arg(short, long)] - id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Delete a contact. - Delete { - #[arg(short, long)] - id: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum TeamAction { - /// List teams. - List { - #[arg(long)] - page: Option, - }, - /// Get a team by ID. - Get { - #[arg(short, long)] - id: String, - }, - /// Create a team from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum ServiceProviderAction { - /// List service providers. - List, -} - -#[derive(Subcommand, Debug)] -pub enum MailDomainAction { - /// List mail domains. - List, -} - -pub async fn dispatch_people( - cmd: PeopleCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let people = client.people().await?; - match cmd { - PeopleCommand::Contact { action } => match action { - ContactAction::List { page } => { - let page_data = people.list_contacts(page).await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "EMAIL", "ORGANIZATION"], - |c| { - vec![ - c.id.clone(), - format!( - "{} {}", - c.first_name.as_deref().unwrap_or(""), - c.last_name.as_deref().unwrap_or("") - ) - .trim() - .to_string(), - c.email.clone().unwrap_or_default(), - c.organization.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - ContactAction::Get { id } => { - let item = people.get_contact(&id).await?; - output::render(&item, fmt) - } - ContactAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = people.create_contact(&json).await?; - output::render(&item, fmt) - } - ContactAction::Update { id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = people.update_contact(&id, &json).await?; - output::render(&item, fmt) - } - ContactAction::Delete { id } => { - people.delete_contact(&id).await?; - output::ok(&format!("Deleted contact {id}")); - Ok(()) - } - }, - PeopleCommand::Team { action } => match action { - TeamAction::List { page } => { - let page_data = people.list_teams(page).await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "DESCRIPTION"], - |t| { - vec![ - t.id.clone(), - t.name.clone().unwrap_or_default(), - t.description.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - TeamAction::Get { id } => { - let item = people.get_team(&id).await?; - output::render(&item, fmt) - } - TeamAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = people.create_team(&json).await?; - output::render(&item, fmt) - } - }, - PeopleCommand::ServiceProvider { action } => match action { - ServiceProviderAction::List => { - let page_data = people.list_service_providers().await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "BASE_URL"], - |sp| { - vec![ - sp.id.clone(), - sp.name.clone().unwrap_or_default(), - sp.base_url.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - }, - PeopleCommand::MailDomain { action } => match action { - MailDomainAction::List => { - let page_data = people.list_mail_domains().await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "STATUS"], - |md| { - vec![ - md.id.clone(), - md.name.clone().unwrap_or_default(), - md.status.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - }, - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Docs -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum DocsCommand { - /// Document management. - Document { - #[command(subcommand)] - action: DocumentAction, - }, - /// Template management. - Template { - #[command(subcommand)] - action: TemplateAction, - }, - /// Version history. - Version { - #[command(subcommand)] - action: VersionAction, - }, - /// Invite a user to a document. - Invite { - /// Document ID. - #[arg(short, long)] - id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum DocumentAction { - /// List documents. - List { - #[arg(long)] - page: Option, - }, - /// Get a document by ID. - Get { - #[arg(short, long)] - id: String, - }, - /// Create a document from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Update a document from JSON. - Update { - #[arg(short, long)] - id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Delete a document. - Delete { - #[arg(short, long)] - id: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum TemplateAction { - /// List templates. - List { - #[arg(long)] - page: Option, - }, - /// Create a template from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum VersionAction { - /// List versions of a document. - List { - /// Document ID. - #[arg(short, long)] - id: String, - }, -} - -pub async fn dispatch_docs( - cmd: DocsCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let docs = client.docs().await?; - match cmd { - DocsCommand::Document { action } => match action { - DocumentAction::List { page } => { - let page_data = docs.list_documents(page).await?; - output::render_list( - &page_data.results, - &["ID", "TITLE", "PUBLIC", "UPDATED"], - |d| { - vec![ - d.id.clone(), - d.title.clone().unwrap_or_default(), - d.is_public.map_or("-".into(), |p| p.to_string()), - d.updated_at.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - DocumentAction::Get { id } => { - let item = docs.get_document(&id).await?; - output::render(&item, fmt) - } - DocumentAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = docs.create_document(&json).await?; - output::render(&item, fmt) - } - DocumentAction::Update { id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = docs.update_document(&id, &json).await?; - output::render(&item, fmt) - } - DocumentAction::Delete { id } => { - docs.delete_document(&id).await?; - output::ok(&format!("Deleted document {id}")); - Ok(()) - } - }, - DocsCommand::Template { action } => match action { - TemplateAction::List { page } => { - let page_data = docs.list_templates(page).await?; - output::render_list( - &page_data.results, - &["ID", "TITLE", "PUBLIC"], - |t| { - vec![ - t.id.clone(), - t.title.clone().unwrap_or_default(), - t.is_public.map_or("-".into(), |p| p.to_string()), - ] - }, - fmt, - ) - } - TemplateAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = docs.create_template(&json).await?; - output::render(&item, fmt) - } - }, - DocsCommand::Version { action } => match action { - VersionAction::List { id } => { - let page_data = docs.list_versions(&id).await?; - output::render_list( - &page_data.results, - &["ID", "VERSION", "CREATED"], - |v| { - vec![ - v.id.clone(), - v.version_number.map_or("-".into(), |n| n.to_string()), - v.created_at.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - }, - DocsCommand::Invite { id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = docs.invite_user(&id, &json).await?; - output::render(&item, fmt) - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Meet -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum MeetCommand { - /// Room management. - Room { - #[command(subcommand)] - action: RoomAction, - }, - /// Recording management. - Recording { - #[command(subcommand)] - action: RecordingAction, - }, -} - -#[derive(Subcommand, Debug)] -pub enum RoomAction { - /// List rooms. - List { - #[arg(long)] - page: Option, - }, - /// Get a room by ID. - Get { - #[arg(short, long)] - id: String, - }, - /// Create a room from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Update a room from JSON. - Update { - #[arg(short, long)] - id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Delete a room. - Delete { - #[arg(short, long)] - id: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum RecordingAction { - /// List recordings for a room. - List { - /// Room ID. - #[arg(short, long)] - id: String, - }, -} - -pub async fn dispatch_meet( - cmd: MeetCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let meet = client.meet().await?; - match cmd { - MeetCommand::Room { action } => match action { - RoomAction::List { page } => { - let page_data = meet.list_rooms(page).await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "SLUG", "PUBLIC"], - |r| { - vec![ - r.id.clone(), - r.name.clone().unwrap_or_default(), - r.slug.clone().unwrap_or_default(), - r.is_public.map_or("-".into(), |p| p.to_string()), - ] - }, - fmt, - ) - } - RoomAction::Get { id } => { - let item = meet.get_room(&id).await?; - output::render(&item, fmt) - } - RoomAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = meet.create_room(&json).await?; - output::render(&item, fmt) - } - RoomAction::Update { id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = meet.update_room(&id, &json).await?; - output::render(&item, fmt) - } - RoomAction::Delete { id } => { - meet.delete_room(&id).await?; - output::ok(&format!("Deleted room {id}")); - Ok(()) - } - }, - MeetCommand::Recording { action } => match action { - RecordingAction::List { id } => { - let page_data = meet.list_recordings(&id).await?; - output::render_list( - &page_data.results, - &["ID", "FILENAME", "DURATION", "CREATED"], - |r| { - vec![ - r.id.clone(), - r.filename.clone().unwrap_or_default(), - r.duration.map_or("-".into(), |d| format!("{d:.1}s")), - r.created_at.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - }, - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Drive -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum DriveCommand { - /// File management. - File { - #[command(subcommand)] - action: FileAction, - }, - /// Folder management. - Folder { - #[command(subcommand)] - action: FolderAction, - }, - /// Share a file with a user. - Share { - /// File ID. - #[arg(short, long)] - id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Permission management. - Permission { - #[command(subcommand)] - action: PermissionAction, - }, - /// Upload a local file or directory to a Drive folder. - Upload { - /// Local path to upload (file or directory). - #[arg(short, long)] - path: String, - /// Target Drive folder ID. - #[arg(short = 't', long)] - folder_id: String, - /// Number of concurrent uploads. - #[arg(long, default_value = "3")] - parallel: usize, - }, -} - -#[derive(Subcommand, Debug)] -pub enum FileAction { - /// List files. - List { - #[arg(long)] - page: Option, - }, - /// Get a file by ID. - Get { - #[arg(short, long)] - id: String, - }, - /// Upload a file (JSON metadata). - Upload { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Delete a file. - Delete { - #[arg(short, long)] - id: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum FolderAction { - /// List folders. - List { - #[arg(long)] - page: Option, - }, - /// Create a folder from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum PermissionAction { - /// List permissions for a file. - List { - /// File ID. - #[arg(short, long)] - id: String, - }, -} - -pub async fn dispatch_drive( - cmd: DriveCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let drive = client.drive().await?; - match cmd { - DriveCommand::File { action } => match action { - FileAction::List { page } => { - let page_data = drive.list_files(page).await?; - output::render_list( - &page_data.results, - &["ID", "TITLE", "TYPE", "SIZE", "MIMETYPE"], - |f| { - vec![ - f.id.clone(), - f.title.clone().unwrap_or_default(), - f.item_type.clone().unwrap_or_default(), - f.size.map_or("-".into(), |s| s.to_string()), - f.mimetype.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - FileAction::Get { id } => { - let item = drive.get_file(&id).await?; - output::render(&item, fmt) - } - FileAction::Upload { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = drive.upload_file(&json).await?; - output::render(&item, fmt) - } - FileAction::Delete { id } => { - drive.delete_file(&id).await?; - output::ok(&format!("Deleted file {id}")); - Ok(()) - } - }, - DriveCommand::Folder { action } => match action { - FolderAction::List { page } => { - let page_data = drive.list_folders(page).await?; - output::render_list( - &page_data.results, - &["ID", "TITLE", "CHILDREN", "CREATED"], - |f| { - vec![ - f.id.clone(), - f.title.clone().unwrap_or_default(), - f.numchild.map_or("-".into(), |n| n.to_string()), - f.created_at.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - FolderAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = drive.create_folder(&json).await?; - output::render(&item, fmt) - } - }, - DriveCommand::Share { id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = drive.share_file(&id, &json).await?; - output::render(&item, fmt) - } - DriveCommand::Permission { action } => match action { - PermissionAction::List { id } => { - let page_data = drive.get_permissions(&id).await?; - output::render_list( - &page_data.results, - &["ID", "USER_ID", "ROLE", "READ", "WRITE"], - |p| { - vec![ - p.id.clone(), - p.user_id.clone().unwrap_or_default(), - p.role.clone().unwrap_or_default(), - p.can_read.map_or("-".into(), |v| v.to_string()), - p.can_write.map_or("-".into(), |v| v.to_string()), - ] - }, - fmt, - ) - } - }, - DriveCommand::Upload { path, folder_id, parallel } => { - upload_recursive(drive, &path, &folder_id, parallel).await - } - } -} - -/// A file that needs uploading, collected during the directory-walk phase. -struct UploadJob { - local_path: std::path::PathBuf, - parent_id: String, - file_size: u64, - relative_path: String, -} - -/// Recursively upload a local file or directory to a Drive folder. -async fn upload_recursive( - drive: &super::DriveClient, - local_path: &str, - parent_id: &str, - parallel: usize, -) -> Result<()> { - use indicatif::{HumanBytes, MultiProgress, ProgressBar, ProgressStyle}; - use std::sync::Arc; - use tokio::sync::Semaphore; - - let path = std::path::Path::new(local_path); - if !path.exists() { - return Err(crate::error::SunbeamError::Other(format!( - "Path does not exist: {local_path}" - ))); - } - - // Phase 1 — Walk and collect: create folders sequentially, gather file jobs. - let mut jobs = Vec::new(); - if path.is_file() { - let file_size = std::fs::metadata(path) - .map_err(|e| crate::error::SunbeamError::Other(format!("stat: {e}")))? - .len(); - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed"); - if !filename.starts_with('.') { - jobs.push(UploadJob { - local_path: path.to_path_buf(), - parent_id: parent_id.to_string(), - file_size, - relative_path: filename.to_string(), - }); - } - } else if path.is_dir() { - collect_upload_jobs(drive, path, parent_id, "", &mut jobs).await?; - } else { - return Err(crate::error::SunbeamError::Other(format!( - "Not a file or directory: {local_path}" - ))); - } - - if jobs.is_empty() { - output::ok("Nothing to upload."); - return Ok(()); - } - - let total_files = jobs.len() as u64; - let total_bytes: u64 = jobs.iter().map(|j| j.file_size).sum(); - - // Clear the folder creation line - eprint!("\r\x1b[K"); - - // Phase 2 — Parallel upload with progress bars. - let multi = MultiProgress::new(); - - // Overall bar tracks file count. Bandwidth is computed manually in the message. - let overall_style = ProgressStyle::with_template( - " {spinner:.green} [{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} files {msg}", - ) - .unwrap() - .progress_chars("█▓░"); - let overall = multi.add(ProgressBar::new(total_files)); - overall.set_style(overall_style); - overall.enable_steady_tick(std::time::Duration::from_millis(100)); - let completed_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); - - let file_style = ProgressStyle::with_template( - " {spinner:.cyan} {wide_msg}", - ) - .unwrap(); - - let sem = Arc::new(Semaphore::new(parallel)); - let drive = Arc::new(drive.clone()); - let mut handles = Vec::new(); - let start = std::time::Instant::now(); - - for job in jobs { - let permit = sem.clone().acquire_owned().await.unwrap(); - let drive = Arc::clone(&drive); - let multi = multi.clone(); - let overall = overall.clone(); - let file_style = file_style.clone(); - let job_size = job.file_size; - let completed_bytes = Arc::clone(&completed_bytes); - let total_bytes = total_bytes; - let start = start.clone(); - - let handle = tokio::spawn(async move { - let pb = multi.add(ProgressBar::new_spinner()); - pb.set_style(file_style); - pb.set_message(job.relative_path.clone()); - pb.enable_steady_tick(std::time::Duration::from_millis(80)); - - let result = upload_single_file_with_progress(&drive, &job, &pb).await; - - pb.finish_and_clear(); - multi.remove(&pb); - - // Update overall — increment file count, compute bandwidth from bytes - overall.inc(1); - let done_bytes = completed_bytes.fetch_add(job_size, std::sync::atomic::Ordering::Relaxed) + job_size; - let elapsed = start.elapsed().as_secs_f64(); - let speed = if elapsed > 1.0 { done_bytes as f64 / elapsed } else { 0.0 }; - let remaining = total_bytes.saturating_sub(done_bytes); - let eta = if speed > 0.0 { remaining as f64 / speed } else { 0.0 }; - let eta_m = eta as u64 / 60; - let eta_s = eta as u64 % 60; - overall.set_message(format!( - "{}/{} {}/s ETA: {}m {:02}s", - indicatif::HumanBytes(done_bytes), - indicatif::HumanBytes(total_bytes), - indicatif::HumanBytes(speed as u64), - eta_m, eta_s, - )); - - drop(permit); - result - }); - handles.push(handle); - } - - let mut errors = 0u64; - for handle in handles { - match handle.await { - Ok(Ok(())) => {} - Ok(Err(e)) => { - errors += 1; - multi.suspend(|| eprintln!(" ERROR: {e}")); - } - Err(e) => { - errors += 1; - multi.suspend(|| eprintln!(" ERROR: task panic: {e}")); - } - } - } - - overall.finish_and_clear(); - multi.clear().ok(); - - let elapsed = start.elapsed(); - let secs = elapsed.as_secs_f64(); - let speed = if secs > 0.0 { - total_bytes as f64 / secs - } else { - 0.0 - }; - let mins = elapsed.as_secs() / 60; - let secs_rem = elapsed.as_secs() % 60; - let uploaded = total_files - errors; - if errors > 0 { - println!( - "✓ Uploaded {uploaded}/{total_files} files ({}) in {mins}m {secs_rem}s ({}/s) — {errors} failed", - HumanBytes(total_bytes), - HumanBytes(speed as u64), - ); - } else { - println!( - "✓ Uploaded {total_files} files ({}) in {mins}m {secs_rem}s ({}/s)", - HumanBytes(total_bytes), - HumanBytes(speed as u64), - ); - } - - Ok(()) -} - -/// Phase 1: Walk a directory recursively, create folders in Drive sequentially, -/// and collect [`UploadJob`]s for every regular file. -async fn collect_upload_jobs( - drive: &super::DriveClient, - dir: &std::path::Path, - parent_id: &str, - prefix: &str, - jobs: &mut Vec, -) -> Result<()> { - let dir_name = dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed"); - - // Skip hidden directories - if dir_name.starts_with('.') { - return Ok(()); - } - - // Build the display prefix for children - let display_prefix = if prefix.is_empty() { - dir_name.to_string() - } else { - format!("{prefix}/{dir_name}") - }; - - eprint!("\r\x1b[K Scanning: {display_prefix} "); - - // Check if folder already exists under the parent. - let existing = drive.list_children(parent_id, None).await.ok(); - let existing_folder_id = existing.and_then(|page| { - page.results.iter().find_map(|item| { - let is_folder = item.get("type").and_then(|v| v.as_str()) == Some("folder"); - let title_matches = item.get("title").and_then(|v| v.as_str()) == Some(dir_name); - if is_folder && title_matches { - item.get("id").and_then(|v| v.as_str()).map(String::from) - } else { - None - } - }) - }); - - let folder_id = if let Some(id) = existing_folder_id { - id - } else { - let folder = drive - .create_child( - parent_id, - &serde_json::json!({ - "title": dir_name, - "type": "folder", - }), - ) - .await?; - folder["id"] - .as_str() - .ok_or_else(|| crate::error::SunbeamError::Other("No folder ID in response".into()))? - .to_string() - }; - - // Build a set of existing file titles in this folder to skip duplicates. - let existing_file_titles: std::collections::HashSet = { - let mut titles = std::collections::HashSet::new(); - if let Ok(page) = drive.list_children(&folder_id, None).await { - for item in &page.results { - if item.get("type").and_then(|v| v.as_str()) == Some("file") { - if let Some(title) = item.get("title").and_then(|v| v.as_str()) { - titles.insert(title.to_string()); - } - } - } - } - titles - }; - - let mut entries: Vec<_> = std::fs::read_dir(dir) - .map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))? - .filter_map(|e| e.ok()) - .collect(); - entries.sort_by_key(|e| e.file_name()); - - for entry in entries { - let entry_path = entry.path(); - let name = entry - .file_name() - .to_str() - .unwrap_or_default() - .to_string(); - - // Skip hidden entries - if name.starts_with('.') { - continue; - } - - if entry_path.is_dir() { - Box::pin(collect_upload_jobs( - drive, - &entry_path, - &folder_id, - &display_prefix, - jobs, - )) - .await?; - } else if entry_path.is_file() { - // Skip if a file with this title already exists in the folder. - if existing_file_titles.contains(&name) { - continue; - } - let file_size = std::fs::metadata(&entry_path) - .map_err(|e| crate::error::SunbeamError::Other(format!("stat: {e}")))? - .len(); - jobs.push(UploadJob { - local_path: entry_path, - parent_id: folder_id.clone(), - file_size, - relative_path: format!("{display_prefix}/{name}"), - }); - } - } - - Ok(()) -} - -/// Upload a single file to Drive, updating the progress bar. -/// Retries on 429/500/502/503 up to 5 times with exponential backoff. -async fn upload_single_file_with_progress( - drive: &super::DriveClient, - job: &UploadJob, - pb: &indicatif::ProgressBar, -) -> Result<()> { - let filename = job - .local_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed"); - - // Create the file item in Drive (with retry) - let body = serde_json::json!({ - "title": filename, - "filename": filename, - "type": "file", - }); - let item = retry_drive_call(|| drive.create_child(&job.parent_id, &body), 5).await?; - - let item_id = item["id"] - .as_str() - .ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?; - - let upload_url = item["policy"] - .as_str() - .ok_or_else(|| { - crate::error::SunbeamError::Other( - "No upload policy URL in response \u{2014} is the item a file?".into(), - ) - })?; - - tracing::debug!("S3 presigned URL: {upload_url}"); - - // Read the file and upload to S3 - let data = std::fs::read(&job.local_path) - .map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?; - let len = data.len() as u64; - drive - .upload_to_s3(upload_url, bytes::Bytes::from(data)) - .await?; - pb.set_position(len); - - // Notify Drive the upload is complete (with retry) - retry_drive_call(|| drive.upload_ended(item_id), 5).await?; - - Ok(()) -} - -/// Retry a Drive API call on 429/500/502/503 with exponential backoff. -async fn retry_drive_call(f: F, max_retries: u32) -> Result -where - F: Fn() -> Fut, - Fut: std::future::Future>, -{ - let mut last_err = None; - for attempt in 0..=max_retries { - match f().await { - Ok(v) => return Ok(v), - Err(e) => { - let msg = e.to_string(); - let retryable = msg.contains("429") - || msg.contains("500") - || msg.contains("502") - || msg.contains("503") - || msg.contains("request failed"); - if retryable && attempt < max_retries { - // On 500, try refreshing the SSO token (may have expired) - if msg.contains("500") { - let _ = crate::auth::get_token().await; - } - let delay = std::time::Duration::from_millis( - 500 * 2u64.pow(attempt.min(4)), - ); - tokio::time::sleep(delay).await; - last_err = Some(e); - continue; - } - return Err(e); - } - } - } - Err(last_err.unwrap()) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Mail (Messages) -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum MailCommand { - /// Mailbox management. - Mailbox { - #[command(subcommand)] - action: MailboxAction, - }, - /// Message management. - Message { - #[command(subcommand)] - action: MessageAction, - }, - /// Folder listing. - Folder { - #[command(subcommand)] - action: MailFolderAction, - }, - /// Contact listing. - Contact { - #[command(subcommand)] - action: MailContactAction, - }, -} - -#[derive(Subcommand, Debug)] -pub enum MailboxAction { - /// List mailboxes. - List, - /// Get a mailbox by ID. - Get { - #[arg(short, long)] - id: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum MessageAction { - /// List messages in a mailbox folder. - List { - /// Mailbox ID. - #[arg(short, long)] - id: String, - /// Folder name (e.g. "inbox"). - #[arg(short, long, default_value = "inbox")] - folder: String, - }, - /// Get a message. - Get { - /// Mailbox ID. - #[arg(short, long)] - id: String, - /// Message ID. - #[arg(short, long)] - message_id: String, - }, - /// Send a message from a mailbox. - Send { - /// Mailbox ID. - #[arg(short, long)] - id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum MailFolderAction { - /// List folders in a mailbox. - List { - /// Mailbox ID. - #[arg(short, long)] - id: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum MailContactAction { - /// List contacts in a mailbox. - List { - /// Mailbox ID. - #[arg(short, long)] - id: String, - }, -} - -pub async fn dispatch_mail( - cmd: MailCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let mail = client.messages().await?; - match cmd { - MailCommand::Mailbox { action } => match action { - MailboxAction::List => { - let page_data = mail.list_mailboxes().await?; - output::render_list( - &page_data.results, - &["ID", "EMAIL", "DISPLAY_NAME"], - |m| { - vec![ - m.id.clone(), - m.email.clone().unwrap_or_default(), - m.display_name.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - MailboxAction::Get { id } => { - let item = mail.get_mailbox(&id).await?; - output::render(&item, fmt) - } - }, - MailCommand::Message { action } => match action { - MessageAction::List { id, folder } => { - let page_data = mail.list_messages(&id, &folder).await?; - output::render_list( - &page_data.results, - &["ID", "SUBJECT", "FROM", "READ", "CREATED"], - |m| { - vec![ - m.id.clone(), - m.subject.clone().unwrap_or_default(), - m.from_address.clone().unwrap_or_default(), - m.is_read.map_or("-".into(), |r| r.to_string()), - m.created_at.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - MessageAction::Get { id, message_id } => { - let item = mail.get_message(&id, &message_id).await?; - output::render(&item, fmt) - } - MessageAction::Send { id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = mail.send_message(&id, &json).await?; - output::render(&item, fmt) - } - }, - MailCommand::Folder { action } => match action { - MailFolderAction::List { id } => { - let page_data = mail.list_folders(&id).await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "MESSAGES", "UNREAD"], - |f| { - vec![ - f.id.clone(), - f.name.clone().unwrap_or_default(), - f.message_count.map_or("-".into(), |c| c.to_string()), - f.unread_count.map_or("-".into(), |c| c.to_string()), - ] - }, - fmt, - ) - } - }, - MailCommand::Contact { action } => match action { - MailContactAction::List { id } => { - let page_data = mail.list_contacts(&id).await?; - output::render_list( - &page_data.results, - &["ID", "EMAIL", "DISPLAY_NAME"], - |c| { - vec![ - c.id.clone(), - c.email.clone().unwrap_or_default(), - c.display_name.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - }, - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Calendars -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum CalCommand { - /// Calendar management. - Calendar { - #[command(subcommand)] - action: CalendarAction, - }, - /// Event management. - Event { - #[command(subcommand)] - action: EventAction, - }, - /// RSVP to an event. - Rsvp { - /// Calendar ID. - #[arg(short = 'c', long)] - calendar_id: String, - /// Event ID. - #[arg(short = 'e', long)] - event_id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum CalendarAction { - /// List calendars. - List, - /// Get a calendar by ID. - Get { - #[arg(short, long)] - id: String, - }, - /// Create a calendar from JSON. - Create { - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, -} - -#[derive(Subcommand, Debug)] -pub enum EventAction { - /// List events in a calendar. - List { - /// Calendar ID. - #[arg(short = 'c', long)] - calendar_id: String, - }, - /// Get an event. - Get { - /// Calendar ID. - #[arg(short = 'c', long)] - calendar_id: String, - /// Event ID. - #[arg(short = 'e', long)] - event_id: String, - }, - /// Create an event from JSON. - Create { - /// Calendar ID. - #[arg(short = 'c', long)] - calendar_id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Update an event from JSON. - Update { - /// Calendar ID. - #[arg(short = 'c', long)] - calendar_id: String, - /// Event ID. - #[arg(short = 'e', long)] - event_id: String, - /// JSON body (or "-" to read from stdin). - #[arg(short, long)] - data: Option, - }, - /// Delete an event. - Delete { - /// Calendar ID. - #[arg(short = 'c', long)] - calendar_id: String, - /// Event ID. - #[arg(short = 'e', long)] - event_id: String, - }, -} - -pub async fn dispatch_cal( - cmd: CalCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let cal = client.calendars().await?; - match cmd { - CalCommand::Calendar { action } => match action { - CalendarAction::List => { - let page_data = cal.list_calendars().await?; - output::render_list( - &page_data.results, - &["ID", "NAME", "COLOR", "DEFAULT"], - |c| { - vec![ - c.id.clone(), - c.name.clone().unwrap_or_default(), - c.color.clone().unwrap_or_default(), - c.is_default.map_or("-".into(), |d| d.to_string()), - ] - }, - fmt, - ) - } - CalendarAction::Get { id } => { - let item = cal.get_calendar(&id).await?; - output::render(&item, fmt) - } - CalendarAction::Create { data } => { - let json = output::read_json_input(data.as_deref())?; - let item = cal.create_calendar(&json).await?; - output::render(&item, fmt) - } - }, - CalCommand::Event { action } => match action { - EventAction::List { calendar_id } => { - let page_data = cal.list_events(&calendar_id).await?; - output::render_list( - &page_data.results, - &["ID", "TITLE", "START", "END", "ALL_DAY"], - |e| { - vec![ - e.id.clone(), - e.title.clone().unwrap_or_default(), - e.start.clone().unwrap_or_default(), - e.end.clone().unwrap_or_default(), - e.all_day.map_or("-".into(), |a| a.to_string()), - ] - }, - fmt, - ) - } - EventAction::Get { - calendar_id, - event_id, - } => { - let item = cal.get_event(&calendar_id, &event_id).await?; - output::render(&item, fmt) - } - EventAction::Create { calendar_id, data } => { - let json = output::read_json_input(data.as_deref())?; - let item = cal.create_event(&calendar_id, &json).await?; - output::render(&item, fmt) - } - EventAction::Update { - calendar_id, - event_id, - data, - } => { - let json = output::read_json_input(data.as_deref())?; - let item = cal.update_event(&calendar_id, &event_id, &json).await?; - output::render(&item, fmt) - } - EventAction::Delete { - calendar_id, - event_id, - } => { - cal.delete_event(&calendar_id, &event_id).await?; - output::ok(&format!("Deleted event {event_id}")); - Ok(()) - } - }, - CalCommand::Rsvp { - calendar_id, - event_id, - data, - } => { - let json = output::read_json_input(data.as_deref())?; - cal.rsvp(&calendar_id, &event_id, &json).await?; - output::ok(&format!("RSVP sent for event {event_id}")); - Ok(()) - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Find -// ═══════════════════════════════════════════════════════════════════════════ - -#[derive(Subcommand, Debug)] -pub enum FindCommand { - /// Search across La Suite services. - Search { - /// Search query. - #[arg(short, long)] - query: String, - #[arg(long)] - page: Option, - }, -} - -pub async fn dispatch_find( - cmd: FindCommand, - client: &SunbeamClient, - fmt: OutputFormat, -) -> Result<()> { - let find = client.find().await?; - match cmd { - FindCommand::Search { query, page } => { - let page_data = find.search(&query, page).await?; - output::render_list( - &page_data.results, - &["ID", "TITLE", "SOURCE", "SCORE", "URL"], - |r| { - vec![ - r.id.clone(), - r.title.clone().unwrap_or_default(), - r.source.clone().unwrap_or_default(), - r.score.map_or("-".into(), |s| format!("{s:.2}")), - r.url.clone().unwrap_or_default(), - ] - }, - fmt, - ) - } - } -} diff --git a/sunbeam-sdk/src/lasuite/docs.rs b/sunbeam-sdk/src/lasuite/docs.rs deleted file mode 100644 index 239236e4..00000000 --- a/sunbeam-sdk/src/lasuite/docs.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Docs service client — documents, templates, versions, invitations. - -use crate::client::{AuthMethod, HttpTransport, ServiceClient}; -use crate::error::Result; -use reqwest::Method; -use super::types::*; - -/// Client for the La Suite Docs API. -pub struct DocsClient { - pub(crate) transport: HttpTransport, -} - -impl ServiceClient for DocsClient { - fn service_name(&self) -> &'static str { - "docs" - } - - fn base_url(&self) -> &str { - &self.transport.base_url - } - - fn from_parts(base_url: String, auth: AuthMethod) -> Self { - Self { - transport: HttpTransport::new(&base_url, auth), - } - } -} - -impl DocsClient { - /// Build a DocsClient from domain (e.g. `https://docs.{domain}/api/v1.0`). - pub fn connect(domain: &str) -> Self { - let base_url = format!("https://docs.{domain}/api/v1.0"); - Self::from_parts(base_url, AuthMethod::Bearer(String::new())) - } - - /// Set the bearer token for authentication. - pub fn with_token(mut self, token: &str) -> Self { - self.transport.set_auth(AuthMethod::Bearer(token.to_string())); - self - } - - // -- Documents ---------------------------------------------------------- - - /// List documents with optional pagination. - pub async fn list_documents(&self, page: Option) -> Result> { - let path = match page { - Some(p) => format!("documents/?page={p}"), - None => "documents/".to_string(), - }; - self.transport - .json(Method::GET, &path, Option::<&()>::None, "docs list documents") - .await - } - - /// Get a single document by ID. - pub async fn get_document(&self, id: &str) -> Result { - self.transport - .json( - Method::GET, - &format!("documents/{id}/"), - Option::<&()>::None, - "docs get document", - ) - .await - } - - /// Create a new document. - pub async fn create_document(&self, body: &serde_json::Value) -> Result { - self.transport - .json(Method::POST, "documents/", Some(body), "docs create document") - .await - } - - /// Update a document (partial). - pub async fn update_document(&self, id: &str, body: &serde_json::Value) -> Result { - self.transport - .json( - Method::PATCH, - &format!("documents/{id}/"), - Some(body), - "docs update document", - ) - .await - } - - /// Delete a document. - pub async fn delete_document(&self, id: &str) -> Result<()> { - self.transport - .send( - Method::DELETE, - &format!("documents/{id}/"), - Option::<&()>::None, - "docs delete document", - ) - .await - } - - // -- Templates ---------------------------------------------------------- - - /// List templates with optional pagination. - pub async fn list_templates(&self, page: Option) -> Result> { - let path = match page { - Some(p) => format!("templates/?page={p}"), - None => "templates/".to_string(), - }; - self.transport - .json(Method::GET, &path, Option::<&()>::None, "docs list templates") - .await - } - - /// Create a new template. - pub async fn create_template(&self, body: &serde_json::Value) -> Result