Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
683cec9307
|
|||
|
30dc4f9c5e
|
|||
|
3d2d16d53e
|
|||
|
80ab6d6113
|
|||
|
b08a80d177
|
|||
|
530b2a22b8
|
|||
|
6a2b62dc42
|
|||
|
4d9659a8bb
|
|||
|
cd80a57a40
|
|||
|
de5c807374
|
|||
|
2ab2fd5b8f
|
|||
|
27536b4695
|
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.1.2
|
||||||
|
|
||||||
|
- 30dc4f9 fix(opensearch): make ML model registration idempotent
|
||||||
|
- 3d2d16d feat(secrets): add xchacha20-poly1305 cipher key seeding for Kratos
|
||||||
|
- 80ab6d6 feat: enable Meet external API, fix SDK path
|
||||||
|
- b08a80d refactor: nest infra commands under `sunbeam platform`
|
||||||
|
|
||||||
|
## v1.1.1
|
||||||
|
|
||||||
|
- cd80a57 fix: DynamicBearer auth, retry on 500/429, upload resilience
|
||||||
|
- de5c807 fix: progress bar tracks files not bytes, retry on 502, dedup folders
|
||||||
|
- 2ab2fd5 fix: polish Drive upload progress UI
|
||||||
|
- 27536b4 feat: parallel Drive upload with indicatif progress UI
|
||||||
|
|
||||||
## v1.1.0
|
## v1.1.0
|
||||||
|
|
||||||
- 477006e chore: bump to v1.1.0, update package description
|
- 477006e chore: bump to v1.1.0, update package description
|
||||||
|
|||||||
56
Cargo.lock
generated
56
Cargo.lock
generated
@@ -532,6 +532,19 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "console"
|
||||||
|
version = "0.15.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||||
|
dependencies = [
|
||||||
|
"encode_unicode",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"unicode-width",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -936,6 +949,12 @@ version = "0.2.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "enum-ordinalize"
|
name = "enum-ordinalize"
|
||||||
version = "4.3.2"
|
version = "4.3.2"
|
||||||
@@ -1672,6 +1691,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indicatif"
|
||||||
|
version = "0.17.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
|
||||||
|
dependencies = [
|
||||||
|
"console",
|
||||||
|
"number_prefix",
|
||||||
|
"portable-atomic",
|
||||||
|
"tokio",
|
||||||
|
"unicode-width",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2145,6 +2178,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "number_prefix"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.37.3"
|
version = "0.37.3"
|
||||||
@@ -2478,6 +2517,12 @@ dependencies = [
|
|||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3501,7 +3546,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sunbeam"
|
name = "sunbeam"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3514,7 +3559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sunbeam-sdk"
|
name = "sunbeam-sdk"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -3526,6 +3571,7 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"indicatif",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
"kube",
|
"kube",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -3948,6 +3994,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sunbeam-sdk"
|
name = "sunbeam-sdk"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Sunbeam Studios SDK, CLI, and ecosystem integrations"
|
description = "Sunbeam Studios SDK, CLI, and ecosystem integrations"
|
||||||
repository = "https://src.sunbeam.pt/studio/cli"
|
repository = "https://src.sunbeam.pt/studio/cli"
|
||||||
@@ -55,6 +55,7 @@ base64 = "0.22"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
indicatif = { version = "0.17", features = ["tokio"] }
|
||||||
|
|
||||||
# Certificate generation
|
# Certificate generation
|
||||||
rcgen = "0.14"
|
rcgen = "0.14"
|
||||||
|
|||||||
@@ -674,6 +674,16 @@ pub fn get_gitea_token() -> Result<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get cached SSO access token synchronously (reads from cache file).
|
||||||
|
/// If the token was recently refreshed by the async `get_token()`, this
|
||||||
|
/// returns the fresh one. Used by DynamicBearer for per-request auth.
|
||||||
|
pub fn get_token_sync() -> Result<String> {
|
||||||
|
let cached = read_cache().map_err(|_| {
|
||||||
|
SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.")
|
||||||
|
})?;
|
||||||
|
Ok(cached.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get cached OIDC id_token (JWT).
|
/// Get cached OIDC id_token (JWT).
|
||||||
pub fn get_id_token() -> Result<String> {
|
pub fn get_id_token() -> Result<String> {
|
||||||
let tokens = read_cache().map_err(|_| {
|
let tokens = read_cache().map_err(|_| {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub enum AuthMethod {
|
|||||||
None,
|
None,
|
||||||
/// Bearer token (`Authorization: Bearer <token>`).
|
/// Bearer token (`Authorization: Bearer <token>`).
|
||||||
Bearer(String),
|
Bearer(String),
|
||||||
|
/// Dynamic bearer — resolves token fresh on each request (survives expiry).
|
||||||
|
DynamicBearer,
|
||||||
/// Custom header (e.g. `X-Vault-Token`).
|
/// Custom header (e.g. `X-Vault-Token`).
|
||||||
Header { name: &'static str, value: String },
|
Header { name: &'static str, value: String },
|
||||||
/// Gitea-style PAT (`Authorization: token <pat>`).
|
/// Gitea-style PAT (`Authorization: token <pat>`).
|
||||||
@@ -84,6 +86,12 @@ impl HttpTransport {
|
|||||||
AuthMethod::Bearer(token) => {
|
AuthMethod::Bearer(token) => {
|
||||||
req = req.bearer_auth(token);
|
req = req.bearer_auth(token);
|
||||||
}
|
}
|
||||||
|
AuthMethod::DynamicBearer => {
|
||||||
|
// Resolve token fresh on each request — survives token expiry/refresh.
|
||||||
|
if let Ok(token) = crate::auth::get_token_sync() {
|
||||||
|
req = req.bearer_auth(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
AuthMethod::Header { name, value } => {
|
AuthMethod::Header { name, value } => {
|
||||||
req = req.header(*name, value);
|
req = req.header(*name, value);
|
||||||
}
|
}
|
||||||
@@ -427,64 +435,65 @@ impl SunbeamClient {
|
|||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn people(&self) -> Result<&crate::lasuite::PeopleClient> {
|
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 {
|
self.people.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
|
||||||
let url = format!("https://people.{}/external_api/v1.0", self.domain);
|
let url = format!("https://people.{}/external_api/v1.0", self.domain);
|
||||||
Ok(crate::lasuite::PeopleClient::from_parts(url, AuthMethod::Bearer(token)))
|
Ok(crate::lasuite::PeopleClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> {
|
pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> {
|
||||||
|
self.sso_token().await?;
|
||||||
self.docs.get_or_try_init(|| async {
|
self.docs.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
|
||||||
let url = format!("https://docs.{}/external_api/v1.0", self.domain);
|
let url = format!("https://docs.{}/external_api/v1.0", self.domain);
|
||||||
Ok(crate::lasuite::DocsClient::from_parts(url, AuthMethod::Bearer(token)))
|
Ok(crate::lasuite::DocsClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> {
|
pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> {
|
||||||
|
self.sso_token().await?;
|
||||||
self.meet.get_or_try_init(|| async {
|
self.meet.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
let url = format!("https://meet.{}/external-api/v1.0", self.domain);
|
||||||
let url = format!("https://meet.{}/external_api/v1.0", self.domain);
|
Ok(crate::lasuite::MeetClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
Ok(crate::lasuite::MeetClient::from_parts(url, AuthMethod::Bearer(token)))
|
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> {
|
pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> {
|
||||||
|
self.sso_token().await?;
|
||||||
self.drive.get_or_try_init(|| async {
|
self.drive.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
|
||||||
let url = format!("https://drive.{}/external_api/v1.0", self.domain);
|
let url = format!("https://drive.{}/external_api/v1.0", self.domain);
|
||||||
Ok(crate::lasuite::DriveClient::from_parts(url, AuthMethod::Bearer(token)))
|
Ok(crate::lasuite::DriveClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> {
|
pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> {
|
||||||
|
self.sso_token().await?;
|
||||||
self.messages.get_or_try_init(|| async {
|
self.messages.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
|
||||||
let url = format!("https://mail.{}/external_api/v1.0", self.domain);
|
let url = format!("https://mail.{}/external_api/v1.0", self.domain);
|
||||||
Ok(crate::lasuite::MessagesClient::from_parts(url, AuthMethod::Bearer(token)))
|
Ok(crate::lasuite::MessagesClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> {
|
pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> {
|
||||||
|
self.sso_token().await?;
|
||||||
self.calendars.get_or_try_init(|| async {
|
self.calendars.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
|
||||||
let url = format!("https://calendar.{}/external_api/v1.0", self.domain);
|
let url = format!("https://calendar.{}/external_api/v1.0", self.domain);
|
||||||
Ok(crate::lasuite::CalendarsClient::from_parts(url, AuthMethod::Bearer(token)))
|
Ok(crate::lasuite::CalendarsClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "lasuite")]
|
#[cfg(feature = "lasuite")]
|
||||||
pub async fn find(&self) -> Result<&crate::lasuite::FindClient> {
|
pub async fn find(&self) -> Result<&crate::lasuite::FindClient> {
|
||||||
|
self.sso_token().await?;
|
||||||
self.find.get_or_try_init(|| async {
|
self.find.get_or_try_init(|| async {
|
||||||
let token = self.sso_token().await?;
|
|
||||||
let url = format!("https://find.{}/external_api/v1.0", self.domain);
|
let url = format!("https://find.{}/external_api/v1.0", self.domain);
|
||||||
Ok(crate::lasuite::FindClient::from_parts(url, AuthMethod::Bearer(token)))
|
Ok(crate::lasuite::FindClient::from_parts(url, AuthMethod::DynamicBearer))
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -558,6 +558,9 @@ pub enum DriveCommand {
|
|||||||
/// Target Drive folder ID.
|
/// Target Drive folder ID.
|
||||||
#[arg(short = 't', long)]
|
#[arg(short = 't', long)]
|
||||||
folder_id: String,
|
folder_id: String,
|
||||||
|
/// Number of concurrent uploads.
|
||||||
|
#[arg(long, default_value = "3")]
|
||||||
|
parallel: usize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,13 +626,14 @@ pub async fn dispatch_drive(
|
|||||||
let page_data = drive.list_files(page).await?;
|
let page_data = drive.list_files(page).await?;
|
||||||
output::render_list(
|
output::render_list(
|
||||||
&page_data.results,
|
&page_data.results,
|
||||||
&["ID", "NAME", "SIZE", "MIME_TYPE"],
|
&["ID", "TITLE", "TYPE", "SIZE", "MIMETYPE"],
|
||||||
|f| {
|
|f| {
|
||||||
vec![
|
vec![
|
||||||
f.id.clone(),
|
f.id.clone(),
|
||||||
f.name.clone().unwrap_or_default(),
|
f.title.clone().unwrap_or_default(),
|
||||||
|
f.item_type.clone().unwrap_or_default(),
|
||||||
f.size.map_or("-".into(), |s| s.to_string()),
|
f.size.map_or("-".into(), |s| s.to_string()),
|
||||||
f.mime_type.clone().unwrap_or_default(),
|
f.mimetype.clone().unwrap_or_default(),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
fmt,
|
fmt,
|
||||||
@@ -655,12 +659,13 @@ pub async fn dispatch_drive(
|
|||||||
let page_data = drive.list_folders(page).await?;
|
let page_data = drive.list_folders(page).await?;
|
||||||
output::render_list(
|
output::render_list(
|
||||||
&page_data.results,
|
&page_data.results,
|
||||||
&["ID", "NAME", "PARENT_ID"],
|
&["ID", "TITLE", "CHILDREN", "CREATED"],
|
||||||
|f| {
|
|f| {
|
||||||
vec![
|
vec![
|
||||||
f.id.clone(),
|
f.id.clone(),
|
||||||
f.name.clone().unwrap_or_default(),
|
f.title.clone().unwrap_or_default(),
|
||||||
f.parent_id.clone().unwrap_or_default(),
|
f.numchild.map_or("-".into(), |n| n.to_string()),
|
||||||
|
f.created_at.clone().unwrap_or_default(),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
fmt,
|
fmt,
|
||||||
@@ -696,18 +701,31 @@ pub async fn dispatch_drive(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DriveCommand::Upload { path, folder_id } => {
|
DriveCommand::Upload { path, folder_id, parallel } => {
|
||||||
upload_recursive(drive, &path, &folder_id).await
|
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.
|
/// Recursively upload a local file or directory to a Drive folder.
|
||||||
async fn upload_recursive(
|
async fn upload_recursive(
|
||||||
drive: &super::DriveClient,
|
drive: &super::DriveClient,
|
||||||
local_path: &str,
|
local_path: &str,
|
||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
|
parallel: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
use indicatif::{HumanBytes, MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
let path = std::path::Path::new(local_path);
|
let path = std::path::Path::new(local_path);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(crate::error::SunbeamError::Other(format!(
|
return Err(crate::error::SunbeamError::Other(format!(
|
||||||
@@ -715,45 +733,232 @@ async fn upload_recursive(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1 — Walk and collect: create folders sequentially, gather file jobs.
|
||||||
|
let mut jobs = Vec::new();
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
upload_single_file(drive, path, parent_id).await
|
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() {
|
} else if path.is_dir() {
|
||||||
upload_directory(drive, path, parent_id).await
|
collect_upload_jobs(drive, path, parent_id, "", &mut jobs).await?;
|
||||||
} else {
|
} else {
|
||||||
Err(crate::error::SunbeamError::Other(format!(
|
return Err(crate::error::SunbeamError::Other(format!(
|
||||||
"Not a file or directory: {local_path}"
|
"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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_directory(
|
/// 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,
|
drive: &super::DriveClient,
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
|
prefix: &str,
|
||||||
|
jobs: &mut Vec<UploadJob>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let dir_name = dir
|
let dir_name = dir
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or("unnamed");
|
.unwrap_or("unnamed");
|
||||||
|
|
||||||
output::step(&format!("Creating folder: {dir_name}"));
|
// Skip hidden directories
|
||||||
|
if dir_name.starts_with('.') {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Create the folder in Drive
|
// Build the display prefix for children
|
||||||
let folder = drive
|
let display_prefix = if prefix.is_empty() {
|
||||||
.create_child(
|
dir_name.to_string()
|
||||||
parent_id,
|
} else {
|
||||||
&serde_json::json!({
|
format!("{prefix}/{dir_name}")
|
||||||
"title": dir_name,
|
};
|
||||||
"type": "folder",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let folder_id = folder["id"]
|
eprint!("\r\x1b[K Scanning: {display_prefix} ");
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| crate::error::SunbeamError::Other("No folder ID in response".into()))?;
|
// 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<String> = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
// Process entries
|
|
||||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||||
.map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))?
|
.map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))?
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
@@ -762,66 +967,132 @@ async fn upload_directory(
|
|||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry_path = entry.path();
|
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() {
|
if entry_path.is_dir() {
|
||||||
Box::pin(upload_directory(drive, &entry_path, folder_id)).await?;
|
Box::pin(collect_upload_jobs(
|
||||||
|
drive,
|
||||||
|
&entry_path,
|
||||||
|
&folder_id,
|
||||||
|
&display_prefix,
|
||||||
|
jobs,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
} else if entry_path.is_file() {
|
} else if entry_path.is_file() {
|
||||||
upload_single_file(drive, &entry_path, folder_id).await?;
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_single_file(
|
/// 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,
|
drive: &super::DriveClient,
|
||||||
file_path: &std::path::Path,
|
job: &UploadJob,
|
||||||
parent_id: &str,
|
pb: &indicatif::ProgressBar,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let filename = file_path
|
let filename = job
|
||||||
|
.local_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or("unnamed");
|
.unwrap_or("unnamed");
|
||||||
|
|
||||||
// Skip hidden files
|
// Create the file item in Drive (with retry)
|
||||||
if filename.starts_with('.') {
|
let body = serde_json::json!({
|
||||||
return Ok(());
|
"title": filename,
|
||||||
}
|
"filename": filename,
|
||||||
|
"type": "file",
|
||||||
output::ok(&format!("Uploading: {filename}"));
|
});
|
||||||
|
let item = retry_drive_call(|| drive.create_child(&job.parent_id, &body), 5).await?;
|
||||||
// Create the file item in Drive
|
|
||||||
let item = drive
|
|
||||||
.create_child(
|
|
||||||
parent_id,
|
|
||||||
&serde_json::json!({
|
|
||||||
"title": filename,
|
|
||||||
"type": "file",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let item_id = item["id"]
|
let item_id = item["id"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?;
|
.ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?;
|
||||||
|
|
||||||
// Get the presigned upload URL (Drive returns it as "policy" on create)
|
|
||||||
let upload_url = item["policy"]
|
let upload_url = item["policy"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| crate::error::SunbeamError::Other("No upload policy URL in response — is the item a file?".into()))?;
|
.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
|
// Read the file and upload to S3
|
||||||
let data = std::fs::read(file_path)
|
let data = std::fs::read(&job.local_path)
|
||||||
.map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?;
|
.map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?;
|
||||||
|
let len = data.len() as u64;
|
||||||
drive
|
drive
|
||||||
.upload_to_s3(upload_url, bytes::Bytes::from(data))
|
.upload_to_s3(upload_url, bytes::Bytes::from(data))
|
||||||
.await?;
|
.await?;
|
||||||
|
pb.set_position(len);
|
||||||
|
|
||||||
// Notify Drive the upload is complete
|
// Notify Drive the upload is complete (with retry)
|
||||||
drive.upload_ended(item_id).await?;
|
retry_drive_call(|| drive.upload_ended(item_id), 5).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retry a Drive API call on 429/500/502/503 with exponential backoff.
|
||||||
|
async fn retry_drive_call<F, Fut, T>(f: F, max_retries: u32) -> Result<T>
|
||||||
|
where
|
||||||
|
F: Fn() -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<T>>,
|
||||||
|
{
|
||||||
|
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)
|
// Mail (Messages)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use reqwest::Method;
|
|||||||
use super::types::*;
|
use super::types::*;
|
||||||
|
|
||||||
/// Client for the La Suite Drive API.
|
/// Client for the La Suite Drive API.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct DriveClient {
|
pub struct DriveClient {
|
||||||
pub(crate) transport: HttpTransport,
|
pub(crate) transport: HttpTransport,
|
||||||
}
|
}
|
||||||
@@ -160,21 +161,39 @@ impl DriveClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upload file bytes directly to a presigned S3 URL.
|
/// Upload file bytes directly to a presigned S3 URL.
|
||||||
|
/// The presigned URL's SigV4 signature covers host + x-amz-acl headers.
|
||||||
|
/// Retries up to 3 times on 502/503/connection errors.
|
||||||
pub async fn upload_to_s3(&self, presigned_url: &str, data: bytes::Bytes) -> Result<()> {
|
pub async fn upload_to_s3(&self, presigned_url: &str, data: bytes::Bytes) -> Result<()> {
|
||||||
let resp = reqwest::Client::new()
|
let max_retries = 3;
|
||||||
.put(presigned_url)
|
for attempt in 0..=max_retries {
|
||||||
.header("Content-Type", "application/octet-stream")
|
let resp = self.transport.http
|
||||||
.body(data)
|
.put(presigned_url)
|
||||||
.send()
|
.header("x-amz-acl", "private")
|
||||||
.await
|
.body(data.clone())
|
||||||
.map_err(|e| crate::error::SunbeamError::network(format!("S3 upload: {e}")))?;
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
match resp {
|
||||||
let status = resp.status();
|
Ok(r) if r.status().is_success() => return Ok(()),
|
||||||
let body = resp.text().await.unwrap_or_default();
|
Ok(r) if (r.status() == 502 || r.status() == 503) && attempt < max_retries => {
|
||||||
return Err(crate::error::SunbeamError::network(format!(
|
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))).await;
|
||||||
"S3 upload: HTTP {status}: {body}"
|
continue;
|
||||||
)));
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
let body = r.text().await.unwrap_or_default();
|
||||||
|
return Err(crate::error::SunbeamError::network(format!(
|
||||||
|
"S3 upload: HTTP {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(_) if attempt < max_retries => {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(crate::error::SunbeamError::network(format!("S3 upload: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,13 +219,17 @@ pub struct DriveFile {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: Option<String>,
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filename: Option<String>,
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
pub item_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub size: Option<u64>,
|
pub size: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mime_type: Option<String>,
|
pub mimetype: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub folder_id: Option<String>,
|
pub upload_state: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -234,15 +238,17 @@ pub struct DriveFile {
|
|||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A folder in the Drive service.
|
/// A folder in the Drive service (same API, type=folder).
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct DriveFolder {
|
pub struct DriveFolder {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: Option<String>,
|
pub title: Option<String>,
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
pub item_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub parent_id: Option<String>,
|
pub numchild: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -617,10 +617,14 @@ async fn ensure_opensearch_ml() {
|
|||||||
already_deployed = true;
|
already_deployed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
"REGISTERED" | "DEPLOYING" => {
|
// Any existing model (even DEPLOY_FAILED) — reuse it instead of
|
||||||
model_id = Some(id.to_string());
|
// registering a new version. This prevents accumulating stale
|
||||||
|
// copies in .plugins-ml-model when the pod restarts.
|
||||||
|
_ => {
|
||||||
|
if model_id.is_none() && !id.is_empty() {
|
||||||
|
model_id = Some(id.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ fn rand_token_n(n: usize) -> String {
|
|||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate an alphanumeric random string of exactly `n` characters.
|
||||||
|
/// Used for secrets that require a fixed character length (e.g. xchacha20-poly1305 cipher keys).
|
||||||
|
pub(crate) fn rand_alphanum(n: usize) -> String {
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::Rng;
|
||||||
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
(0..n).map(|_| CHARSET[OsRng.gen_range(0..CHARSET.len())] as char).collect()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Port-forward helper ─────────────────────────────────────────────────────
|
// ── Port-forward helper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Port-forward guard — cancels the background forwarder on drop.
|
/// Port-forward guard — cancels the background forwarder on drop.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use crate::openbao::BaoClient;
|
|||||||
use crate::output::{ok, warn};
|
use crate::output::{ok, warn};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
gen_dkim_key_pair, gen_fernet_key, port_forward, rand_token, rand_token_n, scw_config,
|
gen_dkim_key_pair, gen_fernet_key, port_forward, rand_alphanum, rand_token, rand_token_n,
|
||||||
wait_pod_running, delete_resource, GITEA_ADMIN_USER, SMTP_URI,
|
scw_config, wait_pod_running, delete_resource, GITEA_ADMIN_USER, SMTP_URI,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Internal result from seed_openbao, used by cmd_seed.
|
/// Internal result from seed_openbao, used by cmd_seed.
|
||||||
@@ -238,12 +238,14 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let smtp_uri_fn = || SMTP_URI.to_string();
|
let smtp_uri_fn = || SMTP_URI.to_string();
|
||||||
|
let cipher_fn = || rand_alphanum(32);
|
||||||
let kratos = get_or_create(
|
let kratos = get_or_create(
|
||||||
&bao,
|
&bao,
|
||||||
"kratos",
|
"kratos",
|
||||||
&[
|
&[
|
||||||
("secrets-default", &rand_token as &dyn Fn() -> String),
|
("secrets-default", &rand_token as &dyn Fn() -> String),
|
||||||
("secrets-cookie", &rand_token),
|
("secrets-cookie", &rand_token),
|
||||||
|
("secrets-cipher", &cipher_fn),
|
||||||
("smtp-connection-uri", &smtp_uri_fn),
|
("smtp-connection-uri", &smtp_uri_fn),
|
||||||
],
|
],
|
||||||
&mut dirty_paths,
|
&mut dirty_paths,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sunbeam"
|
name = "sunbeam"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Sunbeam Studios SDK, CLI, and ecosystem integrations"
|
description = "Sunbeam Studios SDK, CLI, and ecosystem integrations"
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use clap::{Parser, Subcommand};
|
|||||||
|
|
||||||
/// Sunbeam local dev stack manager.
|
/// Sunbeam local dev stack manager.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "sunbeam", about = "Sunbeam local dev stack manager")]
|
#[command(name = "sunbeam", about = "Sunbeam Studios CLI")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Named context to use (overrides current-context from config).
|
/// Named context to use (overrides current-context from config).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -30,18 +30,121 @@ pub struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Verb {
|
pub enum Verb {
|
||||||
// -- Infrastructure commands (preserved) ----------------------------------
|
/// Platform operations (cluster, builds, deploys).
|
||||||
|
Platform {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: PlatformAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Manage sunbeam configuration.
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: Option<ConfigAction>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Project management.
|
||||||
|
Pm {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: Option<PmAction>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Self-update from latest mainline commit.
|
||||||
|
Update,
|
||||||
|
|
||||||
|
/// Print version info.
|
||||||
|
Version,
|
||||||
|
|
||||||
|
// -- Service commands -----------------------------------------------------
|
||||||
|
|
||||||
|
/// Authentication, identity & OAuth2 management.
|
||||||
|
Auth {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::identity::cli::AuthCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Version control.
|
||||||
|
Vcs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::gitea::cli::VcsCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Chat and messaging.
|
||||||
|
Chat {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::matrix::cli::ChatCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Search engine.
|
||||||
|
Search {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::search::cli::SearchCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Object storage.
|
||||||
|
Storage {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::storage::cli::StorageCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Media and video.
|
||||||
|
Media {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::media::cli::MediaCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Monitoring.
|
||||||
|
Mon {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::monitoring::cli::MonCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Secrets management.
|
||||||
|
Vault {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::openbao::cli::VaultCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Video meetings.
|
||||||
|
Meet {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::lasuite::cli::MeetCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// File storage.
|
||||||
|
Drive {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::lasuite::cli::DriveCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Email.
|
||||||
|
Mail {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::lasuite::cli::MailCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Calendar.
|
||||||
|
Cal {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::lasuite::cli::CalCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Search across services.
|
||||||
|
Find {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: sunbeam_sdk::lasuite::cli::FindCommand,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum PlatformAction {
|
||||||
/// Full cluster bring-up.
|
/// Full cluster bring-up.
|
||||||
Up,
|
Up,
|
||||||
|
|
||||||
/// Pod health (optionally scoped).
|
/// Pod health (optionally scoped).
|
||||||
Status {
|
Status {
|
||||||
/// namespace or namespace/name
|
/// namespace or namespace/name
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Build and apply manifests.
|
||||||
/// kustomize build + domain subst + kubectl apply.
|
|
||||||
Apply {
|
Apply {
|
||||||
/// Limit apply to one namespace.
|
/// Limit apply to one namespace.
|
||||||
namespace: Option<String>,
|
namespace: Option<String>,
|
||||||
@@ -55,14 +158,11 @@ pub enum Verb {
|
|||||||
#[arg(long, default_value = "")]
|
#[arg(long, default_value = "")]
|
||||||
email: String,
|
email: String,
|
||||||
},
|
},
|
||||||
|
/// Seed credentials and secrets.
|
||||||
/// Generate/store all credentials in OpenBao.
|
|
||||||
Seed,
|
Seed,
|
||||||
|
/// End-to-end integration test.
|
||||||
/// E2E VSO + OpenBao integration test.
|
|
||||||
Verify,
|
Verify,
|
||||||
|
/// View service logs.
|
||||||
/// kubectl logs for a service.
|
|
||||||
Logs {
|
Logs {
|
||||||
/// namespace/name
|
/// namespace/name
|
||||||
target: String,
|
target: String,
|
||||||
@@ -70,22 +170,19 @@ pub enum Verb {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
follow: bool,
|
follow: bool,
|
||||||
},
|
},
|
||||||
|
/// Get a resource (ns/name).
|
||||||
/// Raw kubectl get for a pod (ns/name).
|
|
||||||
Get {
|
Get {
|
||||||
/// namespace/name
|
/// namespace/name
|
||||||
target: String,
|
target: String,
|
||||||
/// kubectl output format (yaml, json, wide).
|
/// Output format (yaml, json, wide).
|
||||||
#[arg(long = "kubectl-output", default_value = "yaml", value_parser = ["yaml", "json", "wide"])]
|
#[arg(long = "kubectl-output", default_value = "yaml", value_parser = ["yaml", "json", "wide"])]
|
||||||
output: String,
|
output: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Rolling restart of services.
|
/// Rolling restart of services.
|
||||||
Restart {
|
Restart {
|
||||||
/// namespace or namespace/name
|
/// namespace or namespace/name
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Build an artifact.
|
/// Build an artifact.
|
||||||
Build {
|
Build {
|
||||||
/// What to build.
|
/// What to build.
|
||||||
@@ -96,146 +193,25 @@ pub enum Verb {
|
|||||||
/// Apply manifests and rollout restart after pushing (implies --push).
|
/// Apply manifests and rollout restart after pushing (implies --push).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
deploy: bool,
|
deploy: bool,
|
||||||
/// Disable buildkitd layer cache.
|
/// Disable layer cache.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
},
|
},
|
||||||
|
/// Service health checks.
|
||||||
/// Functional service health checks.
|
|
||||||
Check {
|
Check {
|
||||||
/// namespace or namespace/name
|
/// namespace or namespace/name
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Mirror container images.
|
||||||
/// Mirror amd64-only La Suite images.
|
|
||||||
Mirror,
|
Mirror,
|
||||||
|
/// Bootstrap orgs, repos, and services.
|
||||||
/// Create Gitea orgs/repos; bootstrap services.
|
|
||||||
Bootstrap,
|
Bootstrap,
|
||||||
|
|
||||||
/// Manage sunbeam configuration.
|
|
||||||
Config {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: Option<ConfigAction>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// kubectl passthrough.
|
/// kubectl passthrough.
|
||||||
K8s {
|
K8s {
|
||||||
/// arguments forwarded verbatim to kubectl
|
/// arguments forwarded verbatim to kubectl
|
||||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
kubectl_args: Vec<String>,
|
kubectl_args: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// bao CLI passthrough (runs inside OpenBao pod with root token).
|
|
||||||
Bao {
|
|
||||||
/// arguments forwarded verbatim to bao
|
|
||||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
||||||
bao_args: Vec<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Project management across Planka and Gitea.
|
|
||||||
Pm {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: Option<PmAction>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Self-update from latest mainline commit.
|
|
||||||
Update,
|
|
||||||
|
|
||||||
/// Print version info.
|
|
||||||
Version,
|
|
||||||
|
|
||||||
// -- Service commands (new) -----------------------------------------------
|
|
||||||
|
|
||||||
/// Authentication, identity & OAuth2 management.
|
|
||||||
Auth {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::identity::cli::AuthCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Version control (Gitea).
|
|
||||||
Vcs {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::gitea::cli::VcsCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Chat / messaging (Matrix).
|
|
||||||
Chat {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::matrix::cli::ChatCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Search engine (OpenSearch).
|
|
||||||
Search {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::search::cli::SearchCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Object storage (S3).
|
|
||||||
Storage {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::storage::cli::StorageCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Media / video (LiveKit).
|
|
||||||
Media {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::media::cli::MediaCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Monitoring (Prometheus, Loki, Grafana).
|
|
||||||
Mon {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::monitoring::cli::MonCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Secrets management (OpenBao/Vault).
|
|
||||||
Vault {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::openbao::cli::VaultCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// People / contacts (La Suite).
|
|
||||||
People {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::PeopleCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Documents (La Suite).
|
|
||||||
Docs {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::DocsCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Video meetings (La Suite).
|
|
||||||
Meet {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::MeetCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// File storage (La Suite).
|
|
||||||
Drive {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::DriveCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Email (La Suite).
|
|
||||||
Mail {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::MailCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Calendar (La Suite).
|
|
||||||
Cal {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::CalCommand,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Search across La Suite services.
|
|
||||||
Find {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: sunbeam_sdk::lasuite::cli::FindCommand,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -332,16 +308,16 @@ mod tests {
|
|||||||
// 1. test_up
|
// 1. test_up
|
||||||
#[test]
|
#[test]
|
||||||
fn test_up() {
|
fn test_up() {
|
||||||
let cli = parse(&["sunbeam", "up"]);
|
let cli = parse(&["sunbeam", "platform", "up"]);
|
||||||
assert!(matches!(cli.verb, Some(Verb::Up)));
|
assert!(matches!(cli.verb, Some(Verb::Platform { action: PlatformAction::Up })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. test_status_no_target
|
// 2. test_status_no_target
|
||||||
#[test]
|
#[test]
|
||||||
fn test_status_no_target() {
|
fn test_status_no_target() {
|
||||||
let cli = parse(&["sunbeam", "status"]);
|
let cli = parse(&["sunbeam", "platform", "status"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Status { target }) => assert!(target.is_none()),
|
Some(Verb::Platform { action: PlatformAction::Status { target } }) => assert!(target.is_none()),
|
||||||
_ => panic!("expected Status"),
|
_ => panic!("expected Status"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,9 +325,9 @@ mod tests {
|
|||||||
// 3. test_status_with_namespace
|
// 3. test_status_with_namespace
|
||||||
#[test]
|
#[test]
|
||||||
fn test_status_with_namespace() {
|
fn test_status_with_namespace() {
|
||||||
let cli = parse(&["sunbeam", "status", "ory"]);
|
let cli = parse(&["sunbeam", "platform", "status", "ory"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Status { target }) => assert_eq!(target.unwrap(), "ory"),
|
Some(Verb::Platform { action: PlatformAction::Status { target } }) => assert_eq!(target.unwrap(), "ory"),
|
||||||
_ => panic!("expected Status"),
|
_ => panic!("expected Status"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,9 +335,9 @@ mod tests {
|
|||||||
// 4. test_logs_no_follow
|
// 4. test_logs_no_follow
|
||||||
#[test]
|
#[test]
|
||||||
fn test_logs_no_follow() {
|
fn test_logs_no_follow() {
|
||||||
let cli = parse(&["sunbeam", "logs", "ory/kratos"]);
|
let cli = parse(&["sunbeam", "platform", "logs", "ory/kratos"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Logs { target, follow }) => {
|
Some(Verb::Platform { action: PlatformAction::Logs { target, follow } }) => {
|
||||||
assert_eq!(target, "ory/kratos");
|
assert_eq!(target, "ory/kratos");
|
||||||
assert!(!follow);
|
assert!(!follow);
|
||||||
}
|
}
|
||||||
@@ -372,9 +348,9 @@ mod tests {
|
|||||||
// 5. test_logs_follow_short
|
// 5. test_logs_follow_short
|
||||||
#[test]
|
#[test]
|
||||||
fn test_logs_follow_short() {
|
fn test_logs_follow_short() {
|
||||||
let cli = parse(&["sunbeam", "logs", "ory/kratos", "-f"]);
|
let cli = parse(&["sunbeam", "platform", "logs", "ory/kratos", "-f"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Logs { follow, .. }) => assert!(follow),
|
Some(Verb::Platform { action: PlatformAction::Logs { follow, .. } }) => assert!(follow),
|
||||||
_ => panic!("expected Logs"),
|
_ => panic!("expected Logs"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,9 +358,9 @@ mod tests {
|
|||||||
// 6. test_build_proxy
|
// 6. test_build_proxy
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_proxy() {
|
fn test_build_proxy() {
|
||||||
let cli = parse(&["sunbeam", "build", "proxy"]);
|
let cli = parse(&["sunbeam", "platform", "build", "proxy"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, push, deploy, no_cache }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, push, deploy, no_cache } }) => {
|
||||||
assert!(matches!(what, BuildTarget::Proxy));
|
assert!(matches!(what, BuildTarget::Proxy));
|
||||||
assert!(!push);
|
assert!(!push);
|
||||||
assert!(!deploy);
|
assert!(!deploy);
|
||||||
@@ -397,9 +373,9 @@ mod tests {
|
|||||||
// 7. test_build_deploy_flag
|
// 7. test_build_deploy_flag
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_deploy_flag() {
|
fn test_build_deploy_flag() {
|
||||||
let cli = parse(&["sunbeam", "build", "proxy", "--deploy"]);
|
let cli = parse(&["sunbeam", "platform", "build", "proxy", "--deploy"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { deploy, push, no_cache, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { deploy, push, no_cache, .. } }) => {
|
||||||
assert!(deploy);
|
assert!(deploy);
|
||||||
// clap does not imply --push; that logic is in dispatch()
|
// clap does not imply --push; that logic is in dispatch()
|
||||||
assert!(!push);
|
assert!(!push);
|
||||||
@@ -412,16 +388,16 @@ mod tests {
|
|||||||
// 8. test_build_invalid_target
|
// 8. test_build_invalid_target
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_invalid_target() {
|
fn test_build_invalid_target() {
|
||||||
let result = Cli::try_parse_from(&["sunbeam", "build", "notavalidtarget"]);
|
let result = Cli::try_parse_from(&["sunbeam", "platform", "build", "notavalidtarget"]);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12. test_apply_no_namespace
|
// 12. test_apply_no_namespace
|
||||||
#[test]
|
#[test]
|
||||||
fn test_apply_no_namespace() {
|
fn test_apply_no_namespace() {
|
||||||
let cli = parse(&["sunbeam", "apply"]);
|
let cli = parse(&["sunbeam", "platform", "apply"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Apply { namespace, .. }) => assert!(namespace.is_none()),
|
Some(Verb::Platform { action: PlatformAction::Apply { namespace, .. } }) => assert!(namespace.is_none()),
|
||||||
_ => panic!("expected Apply"),
|
_ => panic!("expected Apply"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,9 +405,9 @@ mod tests {
|
|||||||
// 13. test_apply_with_namespace
|
// 13. test_apply_with_namespace
|
||||||
#[test]
|
#[test]
|
||||||
fn test_apply_with_namespace() {
|
fn test_apply_with_namespace() {
|
||||||
let cli = parse(&["sunbeam", "apply", "lasuite"]);
|
let cli = parse(&["sunbeam", "platform", "apply", "lasuite"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Apply { namespace, .. }) => assert_eq!(namespace.unwrap(), "lasuite"),
|
Some(Verb::Platform { action: PlatformAction::Apply { namespace, .. } }) => assert_eq!(namespace.unwrap(), "lasuite"),
|
||||||
_ => panic!("expected Apply"),
|
_ => panic!("expected Apply"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,9 +458,9 @@ mod tests {
|
|||||||
// 17. test_get_json_output
|
// 17. test_get_json_output
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_json_output() {
|
fn test_get_json_output() {
|
||||||
let cli = parse(&["sunbeam", "get", "ory/kratos-abc", "--kubectl-output", "json"]);
|
let cli = parse(&["sunbeam", "platform", "get", "ory/kratos-abc", "--kubectl-output", "json"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Get { target, output }) => {
|
Some(Verb::Platform { action: PlatformAction::Get { target, output } }) => {
|
||||||
assert_eq!(target, "ory/kratos-abc");
|
assert_eq!(target, "ory/kratos-abc");
|
||||||
assert_eq!(output, "json");
|
assert_eq!(output, "json");
|
||||||
}
|
}
|
||||||
@@ -495,9 +471,9 @@ mod tests {
|
|||||||
// 18. test_check_with_target
|
// 18. test_check_with_target
|
||||||
#[test]
|
#[test]
|
||||||
fn test_check_with_target() {
|
fn test_check_with_target() {
|
||||||
let cli = parse(&["sunbeam", "check", "devtools"]);
|
let cli = parse(&["sunbeam", "platform", "check", "devtools"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Check { target }) => assert_eq!(target.unwrap(), "devtools"),
|
Some(Verb::Platform { action: PlatformAction::Check { target } }) => assert_eq!(target.unwrap(), "devtools"),
|
||||||
_ => panic!("expected Check"),
|
_ => panic!("expected Check"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,9 +481,9 @@ mod tests {
|
|||||||
// 19. test_build_messages_components
|
// 19. test_build_messages_components
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_messages_backend() {
|
fn test_build_messages_backend() {
|
||||||
let cli = parse(&["sunbeam", "build", "messages-backend"]);
|
let cli = parse(&["sunbeam", "platform", "build", "messages-backend"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => {
|
||||||
assert!(matches!(what, BuildTarget::MessagesBackend));
|
assert!(matches!(what, BuildTarget::MessagesBackend));
|
||||||
}
|
}
|
||||||
_ => panic!("expected Build"),
|
_ => panic!("expected Build"),
|
||||||
@@ -516,9 +492,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_messages_frontend() {
|
fn test_build_messages_frontend() {
|
||||||
let cli = parse(&["sunbeam", "build", "messages-frontend"]);
|
let cli = parse(&["sunbeam", "platform", "build", "messages-frontend"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => {
|
||||||
assert!(matches!(what, BuildTarget::MessagesFrontend));
|
assert!(matches!(what, BuildTarget::MessagesFrontend));
|
||||||
}
|
}
|
||||||
_ => panic!("expected Build"),
|
_ => panic!("expected Build"),
|
||||||
@@ -527,9 +503,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_messages_mta_in() {
|
fn test_build_messages_mta_in() {
|
||||||
let cli = parse(&["sunbeam", "build", "messages-mta-in"]);
|
let cli = parse(&["sunbeam", "platform", "build", "messages-mta-in"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => {
|
||||||
assert!(matches!(what, BuildTarget::MessagesMtaIn));
|
assert!(matches!(what, BuildTarget::MessagesMtaIn));
|
||||||
}
|
}
|
||||||
_ => panic!("expected Build"),
|
_ => panic!("expected Build"),
|
||||||
@@ -538,9 +514,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_messages_mta_out() {
|
fn test_build_messages_mta_out() {
|
||||||
let cli = parse(&["sunbeam", "build", "messages-mta-out"]);
|
let cli = parse(&["sunbeam", "platform", "build", "messages-mta-out"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => {
|
||||||
assert!(matches!(what, BuildTarget::MessagesMtaOut));
|
assert!(matches!(what, BuildTarget::MessagesMtaOut));
|
||||||
}
|
}
|
||||||
_ => panic!("expected Build"),
|
_ => panic!("expected Build"),
|
||||||
@@ -549,9 +525,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_messages_mpa() {
|
fn test_build_messages_mpa() {
|
||||||
let cli = parse(&["sunbeam", "build", "messages-mpa"]);
|
let cli = parse(&["sunbeam", "platform", "build", "messages-mpa"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => {
|
||||||
assert!(matches!(what, BuildTarget::MessagesMpa));
|
assert!(matches!(what, BuildTarget::MessagesMpa));
|
||||||
}
|
}
|
||||||
_ => panic!("expected Build"),
|
_ => panic!("expected Build"),
|
||||||
@@ -560,9 +536,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_messages_socks_proxy() {
|
fn test_build_messages_socks_proxy() {
|
||||||
let cli = parse(&["sunbeam", "build", "messages-socks-proxy"]);
|
let cli = parse(&["sunbeam", "platform", "build", "messages-socks-proxy"]);
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
Some(Verb::Build { what, .. }) => {
|
Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => {
|
||||||
assert!(matches!(what, BuildTarget::MessagesSocksProxy));
|
assert!(matches!(what, BuildTarget::MessagesSocksProxy));
|
||||||
}
|
}
|
||||||
_ => panic!("expected Build"),
|
_ => panic!("expected Build"),
|
||||||
@@ -643,18 +619,6 @@ mod tests {
|
|||||||
assert!(matches!(cli.verb, Some(Verb::Vault { .. })));
|
assert!(matches!(cli.verb, Some(Verb::Vault { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_people_contact_list() {
|
|
||||||
let cli = parse(&["sunbeam", "people", "contact", "list"]);
|
|
||||||
assert!(matches!(cli.verb, Some(Verb::People { .. })));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_docs_document_list() {
|
|
||||||
let cli = parse(&["sunbeam", "docs", "document", "list"]);
|
|
||||||
assert!(matches!(cli.verb, Some(Verb::Docs { .. })));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_meet_room_list() {
|
fn test_meet_room_list() {
|
||||||
let cli = parse(&["sunbeam", "meet", "room", "list"]);
|
let cli = parse(&["sunbeam", "meet", "room", "list"]);
|
||||||
@@ -694,12 +658,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_infra_commands_preserved() {
|
fn test_infra_commands_preserved() {
|
||||||
// Verify all old infra commands still parse
|
// Verify all old infra commands still parse under platform
|
||||||
assert!(matches!(parse(&["sunbeam", "up"]).verb, Some(Verb::Up)));
|
assert!(matches!(parse(&["sunbeam", "platform", "up"]).verb, Some(Verb::Platform { action: PlatformAction::Up })));
|
||||||
assert!(matches!(parse(&["sunbeam", "seed"]).verb, Some(Verb::Seed)));
|
assert!(matches!(parse(&["sunbeam", "platform", "seed"]).verb, Some(Verb::Platform { action: PlatformAction::Seed })));
|
||||||
assert!(matches!(parse(&["sunbeam", "verify"]).verb, Some(Verb::Verify)));
|
assert!(matches!(parse(&["sunbeam", "platform", "verify"]).verb, Some(Verb::Platform { action: PlatformAction::Verify })));
|
||||||
assert!(matches!(parse(&["sunbeam", "mirror"]).verb, Some(Verb::Mirror)));
|
assert!(matches!(parse(&["sunbeam", "platform", "mirror"]).verb, Some(Verb::Platform { action: PlatformAction::Mirror })));
|
||||||
assert!(matches!(parse(&["sunbeam", "bootstrap"]).verb, Some(Verb::Bootstrap)));
|
assert!(matches!(parse(&["sunbeam", "platform", "bootstrap"]).verb, Some(Verb::Platform { action: PlatformAction::Bootstrap })));
|
||||||
assert!(matches!(parse(&["sunbeam", "update"]).verb, Some(Verb::Update)));
|
assert!(matches!(parse(&["sunbeam", "update"]).verb, Some(Verb::Update)));
|
||||||
assert!(matches!(parse(&["sunbeam", "version"]).verb, Some(Verb::Version)));
|
assert!(matches!(parse(&["sunbeam", "version"]).verb, Some(Verb::Version)));
|
||||||
}
|
}
|
||||||
@@ -739,77 +703,83 @@ pub async fn dispatch() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Verb::Up) => sunbeam_sdk::cluster::cmd_up().await,
|
Some(Verb::Platform { action }) => match action {
|
||||||
|
PlatformAction::Up => sunbeam_sdk::cluster::cmd_up().await,
|
||||||
|
|
||||||
Some(Verb::Status { target }) => {
|
PlatformAction::Status { target } => {
|
||||||
sunbeam_sdk::services::cmd_status(target.as_deref()).await
|
sunbeam_sdk::services::cmd_status(target.as_deref()).await
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Apply {
|
|
||||||
namespace,
|
|
||||||
apply_all,
|
|
||||||
domain,
|
|
||||||
email,
|
|
||||||
}) => {
|
|
||||||
let is_production = !sunbeam_sdk::config::active_context().ssh_host.is_empty();
|
|
||||||
let env_str = if is_production { "production" } else { "local" };
|
|
||||||
let domain = if domain.is_empty() {
|
|
||||||
cli.domain.clone()
|
|
||||||
} else {
|
|
||||||
domain
|
|
||||||
};
|
|
||||||
let email = if email.is_empty() {
|
|
||||||
cli.email.clone()
|
|
||||||
} else {
|
|
||||||
email
|
|
||||||
};
|
|
||||||
let ns = namespace.unwrap_or_default();
|
|
||||||
|
|
||||||
// Production full-apply requires --all or confirmation
|
|
||||||
if is_production && ns.is_empty() && !apply_all {
|
|
||||||
sunbeam_sdk::output::warn(
|
|
||||||
"This will apply ALL namespaces to production.",
|
|
||||||
);
|
|
||||||
eprint!(" Continue? [y/N] ");
|
|
||||||
let mut answer = String::new();
|
|
||||||
std::io::stdin().read_line(&mut answer)?;
|
|
||||||
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
|
|
||||||
println!("Aborted.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sunbeam_sdk::manifests::cmd_apply(&env_str, &domain, &email, &ns).await
|
PlatformAction::Apply {
|
||||||
}
|
namespace,
|
||||||
|
apply_all,
|
||||||
|
domain,
|
||||||
|
email,
|
||||||
|
} => {
|
||||||
|
let is_production = !sunbeam_sdk::config::active_context().ssh_host.is_empty();
|
||||||
|
let env_str = if is_production { "production" } else { "local" };
|
||||||
|
let domain = if domain.is_empty() {
|
||||||
|
cli.domain.clone()
|
||||||
|
} else {
|
||||||
|
domain
|
||||||
|
};
|
||||||
|
let email = if email.is_empty() {
|
||||||
|
cli.email.clone()
|
||||||
|
} else {
|
||||||
|
email
|
||||||
|
};
|
||||||
|
let ns = namespace.unwrap_or_default();
|
||||||
|
|
||||||
Some(Verb::Seed) => sunbeam_sdk::secrets::cmd_seed().await,
|
// Production full-apply requires --all or confirmation
|
||||||
|
if is_production && ns.is_empty() && !apply_all {
|
||||||
|
sunbeam_sdk::output::warn(
|
||||||
|
"This will apply ALL namespaces to production.",
|
||||||
|
);
|
||||||
|
eprint!(" Continue? [y/N] ");
|
||||||
|
let mut answer = String::new();
|
||||||
|
std::io::stdin().read_line(&mut answer)?;
|
||||||
|
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||||
|
println!("Aborted.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some(Verb::Verify) => sunbeam_sdk::secrets::cmd_verify().await,
|
sunbeam_sdk::manifests::cmd_apply(&env_str, &domain, &email, &ns).await
|
||||||
|
}
|
||||||
|
|
||||||
Some(Verb::Logs { target, follow }) => {
|
PlatformAction::Seed => sunbeam_sdk::secrets::cmd_seed().await,
|
||||||
sunbeam_sdk::services::cmd_logs(&target, follow).await
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Get { target, output }) => {
|
PlatformAction::Verify => sunbeam_sdk::secrets::cmd_verify().await,
|
||||||
sunbeam_sdk::services::cmd_get(&target, &output).await
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Restart { target }) => {
|
PlatformAction::Logs { target, follow } => {
|
||||||
sunbeam_sdk::services::cmd_restart(target.as_deref()).await
|
sunbeam_sdk::services::cmd_logs(&target, follow).await
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Verb::Build { what, push, deploy, no_cache }) => {
|
PlatformAction::Get { target, output } => {
|
||||||
let push = push || deploy;
|
sunbeam_sdk::services::cmd_get(&target, &output).await
|
||||||
sunbeam_sdk::images::cmd_build(&what, push, deploy, no_cache).await
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Check { target }) => {
|
PlatformAction::Restart { target } => {
|
||||||
sunbeam_sdk::checks::cmd_check(target.as_deref()).await
|
sunbeam_sdk::services::cmd_restart(target.as_deref()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Verb::Mirror) => sunbeam_sdk::images::cmd_mirror().await,
|
PlatformAction::Build { what, push, deploy, no_cache } => {
|
||||||
|
let push = push || deploy;
|
||||||
|
sunbeam_sdk::images::cmd_build(&what, push, deploy, no_cache).await
|
||||||
|
}
|
||||||
|
|
||||||
Some(Verb::Bootstrap) => sunbeam_sdk::gitea::cmd_bootstrap().await,
|
PlatformAction::Check { target } => {
|
||||||
|
sunbeam_sdk::checks::cmd_check(target.as_deref()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
PlatformAction::Mirror => sunbeam_sdk::images::cmd_mirror().await,
|
||||||
|
|
||||||
|
PlatformAction::Bootstrap => sunbeam_sdk::gitea::cmd_bootstrap().await,
|
||||||
|
|
||||||
|
PlatformAction::K8s { kubectl_args } => {
|
||||||
|
sunbeam_sdk::kube::cmd_k8s(&kubectl_args).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
Some(Verb::Config { action }) => match action {
|
Some(Verb::Config { action }) => match action {
|
||||||
None => {
|
None => {
|
||||||
@@ -908,14 +878,6 @@ pub async fn dispatch() -> Result<()> {
|
|||||||
Some(ConfigAction::Clear) => sunbeam_sdk::config::clear_config(),
|
Some(ConfigAction::Clear) => sunbeam_sdk::config::clear_config(),
|
||||||
},
|
},
|
||||||
|
|
||||||
Some(Verb::K8s { kubectl_args }) => {
|
|
||||||
sunbeam_sdk::kube::cmd_k8s(&kubectl_args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Bao { bao_args }) => {
|
|
||||||
sunbeam_sdk::kube::cmd_bao(&bao_args).await
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Auth { action }) => {
|
Some(Verb::Auth { action }) => {
|
||||||
let sc = sunbeam_sdk::client::SunbeamClient::from_context(
|
let sc = sunbeam_sdk::client::SunbeamClient::from_context(
|
||||||
&sunbeam_sdk::config::active_context(),
|
&sunbeam_sdk::config::active_context(),
|
||||||
@@ -972,20 +934,6 @@ pub async fn dispatch() -> Result<()> {
|
|||||||
sunbeam_sdk::openbao::cli::dispatch(action, &sc, cli.output_format).await
|
sunbeam_sdk::openbao::cli::dispatch(action, &sc, cli.output_format).await
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Verb::People { action }) => {
|
|
||||||
let sc = sunbeam_sdk::client::SunbeamClient::from_context(
|
|
||||||
&sunbeam_sdk::config::active_context(),
|
|
||||||
);
|
|
||||||
sunbeam_sdk::lasuite::cli::dispatch_people(action, &sc, cli.output_format).await
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Docs { action }) => {
|
|
||||||
let sc = sunbeam_sdk::client::SunbeamClient::from_context(
|
|
||||||
&sunbeam_sdk::config::active_context(),
|
|
||||||
);
|
|
||||||
sunbeam_sdk::lasuite::cli::dispatch_docs(action, &sc, cli.output_format).await
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Verb::Meet { action }) => {
|
Some(Verb::Meet { action }) => {
|
||||||
let sc = sunbeam_sdk::client::SunbeamClient::from_context(
|
let sc = sunbeam_sdk::client::SunbeamClient::from_context(
|
||||||
&sunbeam_sdk::config::active_context(),
|
&sunbeam_sdk::config::active_context(),
|
||||||
|
|||||||
Reference in New Issue
Block a user