refactor(sdk): remove lasuite from sunbeam-sdk
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -161,16 +161,6 @@ fn check_registry() -> Vec<CheckEntry> {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<crate::monitoring::LokiClient>,
|
||||
#[cfg(feature = "monitoring")]
|
||||
grafana: OnceCell<crate::monitoring::GrafanaClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
people: OnceCell<crate::lasuite::PeopleClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
docs: OnceCell<crate::lasuite::DocsClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
meet: OnceCell<crate::lasuite::MeetClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
drive: OnceCell<crate::lasuite::DriveClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
messages: OnceCell<crate::lasuite::MessagesClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
calendars: OnceCell<crate::lasuite::CalendarsClient>,
|
||||
#[cfg(feature = "lasuite")]
|
||||
find: OnceCell<crate::lasuite::FindClient>,
|
||||
bao: OnceCell<crate::openbao::BaoClient>,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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<PathBuf>)> {
|
||||
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 <name>`.
|
||||
// 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<PathBuf> = 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(())
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.<domain>),
|
||||
/// 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<String> {
|
||||
// 1. Gitea inline-config secret
|
||||
@@ -426,25 +426,7 @@ pub async fn get_domain() -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: lasuite-oidc-provider configmap
|
||||
{
|
||||
let client = get_client().await?;
|
||||
let api: Api<k8s_openapi::api::core::v1::ConfigMap> =
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -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<DRFPage<Calendar>> {
|
||||
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<Calendar> {
|
||||
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<Calendar> {
|
||||
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<DRFPage<CalEvent>> {
|
||||
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<CalEvent> {
|
||||
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<CalEvent> {
|
||||
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<CalEvent> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<u32>) -> Result<DRFPage<Document>> {
|
||||
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<Document> {
|
||||
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<Document> {
|
||||
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<Document> {
|
||||
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<u32>) -> Result<DRFPage<Template>> {
|
||||
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<Template> {
|
||||
self.transport
|
||||
.json(Method::POST, "templates/", Some(body), "docs create template")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Versions -----------------------------------------------------------
|
||||
|
||||
/// List versions of a document.
|
||||
pub async fn list_versions(&self, doc_id: &str) -> Result<DRFPage<DocVersion>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("documents/{doc_id}/versions/"),
|
||||
Option::<&()>::None,
|
||||
"docs list versions",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Invitations --------------------------------------------------------
|
||||
|
||||
/// Invite a user to collaborate on a document.
|
||||
pub async fn invite_user(
|
||||
&self,
|
||||
doc_id: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Invitation> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("documents/{doc_id}/invitations/"),
|
||||
Some(body),
|
||||
"docs invite user",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = DocsClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://docs.sunbeam.pt/api/v1.0");
|
||||
assert_eq!(c.service_name(), "docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = DocsClient::from_parts(
|
||||
"http://localhost:8000/api/v1.0".into(),
|
||||
AuthMethod::Bearer("tok".into()),
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
//! Drive service client — files, folders, shares, permissions.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::*;
|
||||
|
||||
/// Client for the La Suite Drive API.
|
||||
#[derive(Clone)]
|
||||
pub struct DriveClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for DriveClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"drive"
|
||||
}
|
||||
|
||||
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 DriveClient {
|
||||
/// Build a DriveClient from domain (e.g. `https://drive.{domain}/api/v1.0`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://drive.{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
|
||||
}
|
||||
|
||||
// -- Items --------------------------------------------------------------
|
||||
|
||||
/// List items with optional pagination and type filter.
|
||||
pub async fn list_items(
|
||||
&self,
|
||||
page: Option<u32>,
|
||||
item_type: Option<&str>,
|
||||
) -> Result<DRFPage<DriveFile>> {
|
||||
let mut path = String::from("items/?");
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("page={p}&"));
|
||||
}
|
||||
if let Some(t) = item_type {
|
||||
path.push_str(&format!("type={t}&"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list items")
|
||||
.await
|
||||
}
|
||||
|
||||
/// List files (items with type=file).
|
||||
pub async fn list_files(&self, page: Option<u32>) -> Result<DRFPage<DriveFile>> {
|
||||
self.list_items(page, Some("file")).await
|
||||
}
|
||||
|
||||
/// List folders (items with type=folder).
|
||||
pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> {
|
||||
let mut path = String::from("items/?type=folder&");
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("page={p}&"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list folders")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single item by ID.
|
||||
pub async fn get_file(&self, id: &str) -> Result<DriveFile> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("items/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"drive get item",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new item (file or folder) at the root level.
|
||||
pub async fn upload_file(&self, body: &serde_json::Value) -> Result<DriveFile> {
|
||||
self.transport
|
||||
.json(Method::POST, "items/", Some(body), "drive create item")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete an item.
|
||||
pub async fn delete_file(&self, id: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("items/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"drive delete item",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new folder at the root level.
|
||||
pub async fn create_folder(&self, body: &serde_json::Value) -> Result<DriveFolder> {
|
||||
self.transport
|
||||
.json(Method::POST, "items/", Some(body), "drive create folder")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Items (children API) ------------------------------------------------
|
||||
|
||||
/// Create a child item under a parent folder.
|
||||
/// Returns the created item including its upload_url for files.
|
||||
pub async fn create_child(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("items/{parent_id}/children/"),
|
||||
Some(body),
|
||||
"drive create child",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// List children of an item (folder).
|
||||
pub async fn list_children(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
page: Option<u32>,
|
||||
) -> Result<DRFPage<serde_json::Value>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("items/{parent_id}/children/?page={p}"),
|
||||
None => format!("items/{parent_id}/children/"),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "drive list children")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Notify Drive that a file upload to S3 is complete.
|
||||
pub async fn upload_ended(&self, item_id: &str) -> Result<serde_json::Value> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("items/{item_id}/upload-ended/"),
|
||||
Option::<&()>::None,
|
||||
"drive upload ended",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let resp = self.transport.http
|
||||
.put(presigned_url)
|
||||
.header("x-amz-acl", "private")
|
||||
.body(data.clone())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => return Ok(()),
|
||||
Ok(r) if (r.status() == 502 || r.status() == 503) && attempt < max_retries => {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))).await;
|
||||
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(())
|
||||
}
|
||||
|
||||
// -- Shares -------------------------------------------------------------
|
||||
|
||||
/// Share a file with a user.
|
||||
pub async fn share_file(&self, id: &str, body: &serde_json::Value) -> Result<FileShare> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("files/{id}/shares/"),
|
||||
Some(body),
|
||||
"drive share file",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Permissions --------------------------------------------------------
|
||||
|
||||
/// Get permissions for a file.
|
||||
pub async fn get_permissions(&self, id: &str) -> Result<DRFPage<FilePermission>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("files/{id}/permissions/"),
|
||||
Option::<&()>::None,
|
||||
"drive get permissions",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = DriveClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://drive.sunbeam.pt/api/v1.0");
|
||||
assert_eq!(c.service_name(), "drive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = DriveClient::from_parts(
|
||||
"http://localhost:8000/api/v1.0".into(),
|
||||
AuthMethod::Bearer("tok".into()),
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! Find (search) service client.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::*;
|
||||
|
||||
/// Client for the La Suite Find (search) API.
|
||||
pub struct FindClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for FindClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"find"
|
||||
}
|
||||
|
||||
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 FindClient {
|
||||
/// Build a FindClient from domain (e.g. `https://find.{domain}/api/v1.0`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://find.{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
|
||||
}
|
||||
|
||||
/// Search across La Suite services.
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
page: Option<u32>,
|
||||
) -> Result<DRFPage<SearchResult>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("search/?q={query}&page={p}"),
|
||||
None => format!("search/?q={query}"),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "find search")
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = FindClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://find.sunbeam.pt/api/v1.0");
|
||||
assert_eq!(c.service_name(), "find");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = FindClient::from_parts(
|
||||
"http://localhost:8000/api/v1.0".into(),
|
||||
AuthMethod::Bearer("tok".into()),
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//! Meet service client — rooms and recordings.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::*;
|
||||
|
||||
/// Client for the La Suite Meet API.
|
||||
pub struct MeetClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for MeetClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"meet"
|
||||
}
|
||||
|
||||
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 MeetClient {
|
||||
/// Build a MeetClient from domain (e.g. `https://meet.{domain}/api/v1.0`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://meet.{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
|
||||
}
|
||||
|
||||
// -- Rooms --------------------------------------------------------------
|
||||
|
||||
/// List rooms with optional pagination.
|
||||
pub async fn list_rooms(&self, page: Option<u32>) -> Result<DRFPage<MeetRoom>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("rooms/?page={p}"),
|
||||
None => "rooms/".to_string(),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "meet list rooms")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new room.
|
||||
pub async fn create_room(&self, body: &serde_json::Value) -> Result<MeetRoom> {
|
||||
self.transport
|
||||
.json(Method::POST, "rooms/", Some(body), "meet create room")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single room by ID.
|
||||
pub async fn get_room(&self, id: &str) -> Result<MeetRoom> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("rooms/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"meet get room",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a room (partial).
|
||||
pub async fn update_room(&self, id: &str, body: &serde_json::Value) -> Result<MeetRoom> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::PATCH,
|
||||
&format!("rooms/{id}/"),
|
||||
Some(body),
|
||||
"meet update room",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a room.
|
||||
pub async fn delete_room(&self, id: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("rooms/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"meet delete room",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Recordings ---------------------------------------------------------
|
||||
|
||||
/// List recordings for a room.
|
||||
pub async fn list_recordings(&self, room_id: &str) -> Result<DRFPage<Recording>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("rooms/{room_id}/recordings/"),
|
||||
Option::<&()>::None,
|
||||
"meet list recordings",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = MeetClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://meet.sunbeam.pt/api/v1.0");
|
||||
assert_eq!(c.service_name(), "meet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = MeetClient::from_parts(
|
||||
"http://localhost:8000/api/v1.0".into(),
|
||||
AuthMethod::Bearer("tok".into()),
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
//! Messages (mail) service client — mailboxes, messages, folders, contacts.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::*;
|
||||
|
||||
/// Client for the La Suite Messages (mail) API.
|
||||
pub struct MessagesClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for MessagesClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"messages"
|
||||
}
|
||||
|
||||
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 MessagesClient {
|
||||
/// Build a MessagesClient from domain (e.g. `https://mail.{domain}/api/v1.0`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://mail.{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
|
||||
}
|
||||
|
||||
// -- Mailboxes ----------------------------------------------------------
|
||||
|
||||
/// List mailboxes.
|
||||
pub async fn list_mailboxes(&self) -> Result<DRFPage<Mailbox>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
"mailboxes/",
|
||||
Option::<&()>::None,
|
||||
"messages list mailboxes",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single mailbox by ID.
|
||||
pub async fn get_mailbox(&self, id: &str) -> Result<Mailbox> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("mailboxes/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"messages get mailbox",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Messages -----------------------------------------------------------
|
||||
|
||||
/// List messages in a mailbox folder.
|
||||
pub async fn list_messages(
|
||||
&self,
|
||||
mailbox_id: &str,
|
||||
folder: &str,
|
||||
) -> Result<DRFPage<EmailMessage>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("mailboxes/{mailbox_id}/messages/?folder={folder}"),
|
||||
Option::<&()>::None,
|
||||
"messages list messages",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single message.
|
||||
pub async fn get_message(
|
||||
&self,
|
||||
mailbox_id: &str,
|
||||
message_id: &str,
|
||||
) -> Result<EmailMessage> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("mailboxes/{mailbox_id}/messages/{message_id}/"),
|
||||
Option::<&()>::None,
|
||||
"messages get message",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a message from a mailbox.
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
mailbox_id: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<EmailMessage> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
&format!("mailboxes/{mailbox_id}/messages/"),
|
||||
Some(body),
|
||||
"messages send message",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Folders ------------------------------------------------------------
|
||||
|
||||
/// List folders in a mailbox.
|
||||
pub async fn list_folders(&self, mailbox_id: &str) -> Result<DRFPage<MailFolder>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("mailboxes/{mailbox_id}/folders/"),
|
||||
Option::<&()>::None,
|
||||
"messages list folders",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Contacts -----------------------------------------------------------
|
||||
|
||||
/// List contacts in a mailbox.
|
||||
pub async fn list_contacts(&self, mailbox_id: &str) -> Result<DRFPage<MailContact>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("mailboxes/{mailbox_id}/contacts/"),
|
||||
Option::<&()>::None,
|
||||
"messages list contacts",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = MessagesClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://mail.sunbeam.pt/api/v1.0");
|
||||
assert_eq!(c.service_name(), "messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = MessagesClient::from_parts(
|
||||
"http://localhost:8000/api/v1.0".into(),
|
||||
AuthMethod::Bearer("tok".into()),
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//! La Suite service clients (People, Docs, Meet, Drive, Messages, Calendars, Find).
|
||||
|
||||
pub mod people;
|
||||
pub mod docs;
|
||||
pub mod meet;
|
||||
pub mod drive;
|
||||
pub mod messages;
|
||||
pub mod calendars;
|
||||
pub mod find;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
pub mod cli;
|
||||
|
||||
pub use people::PeopleClient;
|
||||
pub use docs::DocsClient;
|
||||
pub use meet::MeetClient;
|
||||
pub use drive::DriveClient;
|
||||
pub use messages::MessagesClient;
|
||||
pub use calendars::CalendarsClient;
|
||||
pub use find::FindClient;
|
||||
@@ -1,178 +0,0 @@
|
||||
//! People service client — contacts, teams, service providers, mail domains.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::*;
|
||||
|
||||
/// Client for the La Suite People API.
|
||||
pub struct PeopleClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for PeopleClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"people"
|
||||
}
|
||||
|
||||
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 PeopleClient {
|
||||
/// Build a PeopleClient from domain (e.g. `https://people.{domain}/api/v1.0`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://people.{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
|
||||
}
|
||||
|
||||
// -- Contacts -----------------------------------------------------------
|
||||
|
||||
/// List contacts with optional pagination.
|
||||
pub async fn list_contacts(&self, page: Option<u32>) -> Result<DRFPage<Contact>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("contacts/?page={p}"),
|
||||
None => "contacts/".to_string(),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "people list contacts")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single contact by ID.
|
||||
pub async fn get_contact(&self, id: &str) -> Result<Contact> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("contacts/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"people get contact",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new contact.
|
||||
pub async fn create_contact(&self, body: &serde_json::Value) -> Result<Contact> {
|
||||
self.transport
|
||||
.json(Method::POST, "contacts/", Some(body), "people create contact")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a contact (partial).
|
||||
pub async fn update_contact(&self, id: &str, body: &serde_json::Value) -> Result<Contact> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::PATCH,
|
||||
&format!("contacts/{id}/"),
|
||||
Some(body),
|
||||
"people update contact",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a contact.
|
||||
pub async fn delete_contact(&self, id: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("contacts/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"people delete contact",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Teams --------------------------------------------------------------
|
||||
|
||||
/// List teams with optional pagination.
|
||||
pub async fn list_teams(&self, page: Option<u32>) -> Result<DRFPage<Team>> {
|
||||
let path = match page {
|
||||
Some(p) => format!("teams/?page={p}"),
|
||||
None => "teams/".to_string(),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "people list teams")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single team by ID.
|
||||
pub async fn get_team(&self, id: &str) -> Result<Team> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("teams/{id}/"),
|
||||
Option::<&()>::None,
|
||||
"people get team",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new team.
|
||||
pub async fn create_team(&self, body: &serde_json::Value) -> Result<Team> {
|
||||
self.transport
|
||||
.json(Method::POST, "teams/", Some(body), "people create team")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Service providers --------------------------------------------------
|
||||
|
||||
/// List service providers.
|
||||
pub async fn list_service_providers(&self) -> Result<DRFPage<ServiceProvider>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
"service-providers/",
|
||||
Option::<&()>::None,
|
||||
"people list service providers",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Mail domains -------------------------------------------------------
|
||||
|
||||
/// List mail domains.
|
||||
pub async fn list_mail_domains(&self) -> Result<DRFPage<MailDomain>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
"mail-domains/",
|
||||
Option::<&()>::None,
|
||||
"people list mail domains",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = PeopleClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://people.sunbeam.pt/api/v1.0");
|
||||
assert_eq!(c.service_name(), "people");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = PeopleClient::from_parts(
|
||||
"http://localhost:8000/api/v1.0".into(),
|
||||
AuthMethod::Bearer("tok".into()),
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
||||
}
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
//! Shared types for La Suite DRF-based services.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DRF paginated response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Standard Django REST Framework paginated list response.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DRFPage<T> {
|
||||
#[serde(default)]
|
||||
pub count: u64,
|
||||
#[serde(default)]
|
||||
pub next: Option<String>,
|
||||
#[serde(default)]
|
||||
pub previous: Option<String>,
|
||||
#[serde(default)]
|
||||
pub results: Vec<T>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// People types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A contact in the People service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Contact {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub first_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub phone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub organization: Option<String>,
|
||||
#[serde(default)]
|
||||
pub job_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A team in the People service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Team {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub members: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A service provider in the People service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ServiceProvider {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A mail domain in the People service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MailDomain {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docs types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A document in the Docs service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_public: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A document template.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Template {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_public: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A document version snapshot.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DocVersion {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub version_number: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// An invitation to collaborate on a document.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Invitation {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meet types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A meeting room.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MeetRoom {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_public: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub configuration: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A recording of a meeting.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Recording {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub room_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drive types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A file in the Drive service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DriveFile {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub item_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mimetype: Option<String>,
|
||||
#[serde(default)]
|
||||
pub upload_state: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A folder in the Drive service (same API, type=folder).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DriveFolder {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub item_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub numchild: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A file sharing record.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FileShare {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub file_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A file permission entry.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FilePermission {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub file_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub can_read: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub can_write: Option<bool>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A mailbox.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Mailbox {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// An email message.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct EmailMessage {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub subject: Option<String>,
|
||||
#[serde(default)]
|
||||
pub from_address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub to_addresses: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cc_addresses: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_read: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub folder: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A mail folder.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MailFolder {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub message_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub unread_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// A mail contact.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MailContact {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calendars types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A calendar.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Calendar {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_default: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A calendar event.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CalEvent {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub start: Option<String>,
|
||||
#[serde(default)]
|
||||
pub end: Option<String>,
|
||||
#[serde(default)]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub all_day: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub attendees: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub calendar_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A search result from the Find service.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub score: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_drf_page_deserialize() {
|
||||
let json = r#"{
|
||||
"count": 2,
|
||||
"next": "https://example.com/api/v1.0/contacts/?page=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{"id": "1", "first_name": "Alice"},
|
||||
{"id": "2", "first_name": "Bob"}
|
||||
]
|
||||
}"#;
|
||||
let page: DRFPage<Contact> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(page.count, 2);
|
||||
assert!(page.next.is_some());
|
||||
assert!(page.previous.is_none());
|
||||
assert_eq!(page.results.len(), 2);
|
||||
assert_eq!(page.results[0].first_name.as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drf_page_empty() {
|
||||
let json = r#"{"count": 0, "next": null, "previous": null, "results": []}"#;
|
||||
let page: DRFPage<Contact> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(page.count, 0);
|
||||
assert!(page.results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drf_page_defaults() {
|
||||
let json = r#"{"results": []}"#;
|
||||
let page: DRFPage<Document> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(page.count, 0);
|
||||
assert!(page.next.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_roundtrip() {
|
||||
let c = Contact {
|
||||
id: "abc".into(),
|
||||
first_name: Some("Alice".into()),
|
||||
last_name: Some("Smith".into()),
|
||||
email: Some("alice@example.com".into()),
|
||||
phone: None,
|
||||
avatar: None,
|
||||
organization: None,
|
||||
job_title: None,
|
||||
notes: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let c2: Contact = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(c2.id, "abc");
|
||||
assert_eq!(c2.first_name.as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_defaults() {
|
||||
let json = r#"{"id": "doc-1"}"#;
|
||||
let d: Document = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(d.id, "doc-1");
|
||||
assert!(d.title.is_none());
|
||||
assert!(d.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meet_room_deserialize() {
|
||||
let json = r#"{"id": "room-1", "name": "Standup", "slug": "standup"}"#;
|
||||
let r: MeetRoom = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r.id, "room-1");
|
||||
assert_eq!(r.name.as_deref(), Some("Standup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calendar_event_deserialize() {
|
||||
let json = r#"{"id": "ev-1", "title": "Lunch", "start": "2026-01-01T12:00:00Z", "all_day": false}"#;
|
||||
let e: CalEvent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(e.id, "ev-1");
|
||||
assert_eq!(e.all_day, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_result_deserialize() {
|
||||
let json = r#"{"id": "sr-1", "title": "Found it", "score": 0.95}"#;
|
||||
let sr: SearchResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(sr.id, "sr-1");
|
||||
assert_eq!(sr.score, Some(0.95));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_message_deserialize() {
|
||||
let json = r#"{"id": "msg-1", "subject": "Hello", "to_addresses": ["bob@example.com"]}"#;
|
||||
let m: EmailMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(m.id, "msg-1");
|
||||
assert_eq!(m.to_addresses.as_ref().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,5 @@ pub mod storage;
|
||||
pub mod media;
|
||||
#[cfg(feature = "monitoring")]
|
||||
pub mod monitoring;
|
||||
#[cfg(feature = "lasuite")]
|
||||
pub mod lasuite;
|
||||
#[cfg(feature = "build")]
|
||||
pub mod build;
|
||||
|
||||
@@ -784,23 +784,23 @@ mod tests {
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meet-config
|
||||
namespace: lasuite
|
||||
name: stalwart-config
|
||||
namespace: stalwart
|
||||
data:
|
||||
FOO: bar
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: meet-backend
|
||||
namespace: lasuite
|
||||
name: stalwart
|
||||
namespace: stalwart
|
||||
spec:
|
||||
replicas: 1
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: lasuite
|
||||
name: stalwart
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -822,14 +822,14 @@ spec:
|
||||
|
||||
#[test]
|
||||
fn test_keeps_matching_namespace() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
assert!(result.contains("name: meet-config"));
|
||||
assert!(result.contains("name: meet-backend"));
|
||||
let result = filter_by_namespace(MULTI_DOC, "stalwart");
|
||||
assert!(result.contains("name: stalwart-config"));
|
||||
assert!(result.contains("name: stalwart\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_excludes_other_namespaces() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
let result = filter_by_namespace(MULTI_DOC, "stalwart");
|
||||
assert!(!result.contains("namespace: ingress"));
|
||||
assert!(!result.contains("name: pingora-config"));
|
||||
assert!(!result.contains("name: pingora\n"));
|
||||
@@ -837,7 +837,7 @@ spec:
|
||||
|
||||
#[test]
|
||||
fn test_includes_namespace_resource_itself() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
let result = filter_by_namespace(MULTI_DOC, "stalwart");
|
||||
assert!(result.contains("kind: Namespace"));
|
||||
}
|
||||
|
||||
@@ -846,7 +846,7 @@ spec:
|
||||
let result = filter_by_namespace(MULTI_DOC, "ingress");
|
||||
assert!(result.contains("name: pingora-config"));
|
||||
assert!(result.contains("name: pingora"));
|
||||
assert!(!result.contains("namespace: lasuite"));
|
||||
assert!(!result.contains("namespace: stalwart"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -857,13 +857,13 @@ spec:
|
||||
|
||||
#[test]
|
||||
fn test_empty_input_returns_empty() {
|
||||
let result = filter_by_namespace("", "lasuite");
|
||||
let result = filter_by_namespace("", "stalwart");
|
||||
assert!(result.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_starts_with_separator() {
|
||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
||||
let result = filter_by_namespace(MULTI_DOC, "stalwart");
|
||||
assert!(result.starts_with("---"));
|
||||
}
|
||||
|
||||
@@ -883,7 +883,7 @@ spec:
|
||||
#[test]
|
||||
fn test_single_doc_not_matching() {
|
||||
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
|
||||
let result = filter_by_namespace(doc, "lasuite");
|
||||
let result = filter_by_namespace(doc, "stalwart");
|
||||
assert!(result.trim().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const PG_USERS: &[&str] = &[
|
||||
"stalwart",
|
||||
];
|
||||
|
||||
const SMTP_URI: &str = "smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true";
|
||||
pub(crate) const SMTP_URI: &str = "smtp://stalwart.stalwart.svc.cluster.local:25/?skip_ssl_verify=true";
|
||||
|
||||
// ── Key generation ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -442,18 +442,6 @@ pub async fn cmd_seed() -> Result<()> {
|
||||
.get("kratos-secrets-cookie")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let hive_oidc_id = creds
|
||||
.get("hive-oidc-client-id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "hive-local".into());
|
||||
let hive_oidc_sec = creds
|
||||
.get("hive-oidc-client-secret")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let django_secret = creds
|
||||
.get("people-django-secret")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let gitea_admin_pass = creds
|
||||
.get("gitea-admin-password")
|
||||
.cloned()
|
||||
@@ -670,31 +658,7 @@ pub async fn cmd_seed() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
k::ensure_ns("lasuite").await?;
|
||||
k::create_secret(
|
||||
"lasuite",
|
||||
"seaweedfs-s3-credentials",
|
||||
HashMap::from([
|
||||
("S3_ACCESS_KEY".into(), s3_access_key),
|
||||
("S3_SECRET_KEY".into(), s3_secret_key),
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
k::create_secret(
|
||||
"lasuite",
|
||||
"hive-oidc",
|
||||
HashMap::from([
|
||||
("client-id".into(), hive_oidc_id),
|
||||
("client-secret".into(), hive_oidc_sec),
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
k::create_secret(
|
||||
"lasuite",
|
||||
"people-django-secret",
|
||||
HashMap::from([("DJANGO_SECRET_KEY".into(), django_secret)]),
|
||||
)
|
||||
.await?;
|
||||
let _ = (s3_access_key, s3_secret_key);
|
||||
|
||||
k::ensure_ns("matrix").await?;
|
||||
k::ensure_ns("media").await?;
|
||||
@@ -1091,7 +1055,7 @@ mod tests {
|
||||
fn test_smtp_uri() {
|
||||
assert_eq!(
|
||||
SMTP_URI,
|
||||
"smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true"
|
||||
"smtp://stalwart.stalwart.svc.cluster.local:25/?skip_ssl_verify=true"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -560,24 +560,6 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Seed resource server allowed audiences for La Suite external APIs.
|
||||
// Combines the static sunbeam-cli client ID with dynamic service client IDs.
|
||||
ok("Configuring La Suite resource server audiences...");
|
||||
{
|
||||
let mut rs_audiences = HashMap::new();
|
||||
// sunbeam-cli is always static (OAuth2Client CRD name)
|
||||
let mut audiences = vec!["sunbeam-cli".to_string()];
|
||||
// Read the messages client ID from the oidc-messages secret if available
|
||||
if let Ok(client_id) = crate::kube::kube_get_secret_field("lasuite", "oidc-messages", "CLIENT_ID").await {
|
||||
audiences.push(client_id);
|
||||
}
|
||||
rs_audiences.insert(
|
||||
"OIDC_RS_ALLOWED_AUDIENCES".to_string(),
|
||||
audiences.join(","),
|
||||
);
|
||||
bao.kv_put("secret", "drive-rs-audiences", &rs_audiences).await?;
|
||||
}
|
||||
|
||||
// Patch gitea admin credentials into secret/sol for Sol's Gitea integration.
|
||||
// Uses kv_patch to preserve manually-set keys (matrix-access-token etc.).
|
||||
{
|
||||
@@ -618,7 +600,7 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
||||
"auth/kubernetes/role/vso",
|
||||
&serde_json::json!({
|
||||
"bound_service_account_names": "default",
|
||||
"bound_service_account_namespaces": "ory,devtools,storage,lasuite,stalwart,matrix,media,data,monitoring",
|
||||
"bound_service_account_namespaces": "ory,devtools,storage,stalwart,matrix,media,data,monitoring,cert-manager,vpn,wfe",
|
||||
"policies": "vso-reader",
|
||||
"ttl": "1h"
|
||||
}),
|
||||
|
||||
@@ -16,12 +16,6 @@ pub const SERVICES_TO_RESTART: &[(&str, &str)] = &[
|
||||
("ory", "login-ui"),
|
||||
("devtools", "gitea"),
|
||||
("storage", "seaweedfs-filer"),
|
||||
("lasuite", "hive"),
|
||||
("lasuite", "people-backend"),
|
||||
("lasuite", "people-frontend"),
|
||||
("lasuite", "people-celery-worker"),
|
||||
("lasuite", "people-celery-beat"),
|
||||
("lasuite", "projects"),
|
||||
("matrix", "tuwunel"),
|
||||
("media", "livekit-server"),
|
||||
];
|
||||
@@ -446,13 +440,15 @@ mod tests {
|
||||
assert!(MANAGED_NS.contains(&"data"));
|
||||
assert!(MANAGED_NS.contains(&"devtools"));
|
||||
assert!(MANAGED_NS.contains(&"ingress"));
|
||||
assert!(MANAGED_NS.contains(&"lasuite"));
|
||||
assert!(MANAGED_NS.contains(&"matrix"));
|
||||
assert!(MANAGED_NS.contains(&"media"));
|
||||
assert!(MANAGED_NS.contains(&"stalwart"));
|
||||
assert!(MANAGED_NS.contains(&"storage"));
|
||||
assert!(MANAGED_NS.contains(&"monitoring"));
|
||||
assert!(MANAGED_NS.contains(&"vault-secrets-operator"));
|
||||
assert_eq!(MANAGED_NS.len(), 10);
|
||||
assert!(MANAGED_NS.contains(&"vpn"));
|
||||
assert!(MANAGED_NS.contains(&"wfe"));
|
||||
assert!(!MANAGED_NS.contains(&"lasuite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -462,10 +458,10 @@ mod tests {
|
||||
assert!(SERVICES_TO_RESTART.contains(&("ory", "login-ui")));
|
||||
assert!(SERVICES_TO_RESTART.contains(&("devtools", "gitea")));
|
||||
assert!(SERVICES_TO_RESTART.contains(&("storage", "seaweedfs-filer")));
|
||||
assert!(SERVICES_TO_RESTART.contains(&("lasuite", "hive")));
|
||||
assert!(SERVICES_TO_RESTART.contains(&("matrix", "tuwunel")));
|
||||
assert!(SERVICES_TO_RESTART.contains(&("media", "livekit-server")));
|
||||
assert_eq!(SERVICES_TO_RESTART.len(), 13);
|
||||
assert!(!SERVICES_TO_RESTART.iter().any(|(ns, _)| *ns == "lasuite"));
|
||||
assert_eq!(SERVICES_TO_RESTART.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -516,7 +512,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_restart_filter_all() {
|
||||
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART.to_vec();
|
||||
assert_eq!(matched.len(), 13);
|
||||
assert_eq!(matched.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,216 +11,11 @@ use super::{
|
||||
next_employee_id, short_id, PortForward, SMTP_LOCAL_PORT,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App-level provisioning (best-effort)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a deployment to the name of a running pod.
|
||||
async fn pod_for_deployment(ns: &str, deployment: &str) -> Result<String> {
|
||||
let client = crate::kube::get_client().await?;
|
||||
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
|
||||
kube::Api::namespaced(client.clone(), ns);
|
||||
|
||||
let label = format!("app.kubernetes.io/name={deployment}");
|
||||
let lp = kube::api::ListParams::default().labels(&label);
|
||||
let pod_list = pods
|
||||
.list(&lp)
|
||||
.await
|
||||
.with_ctx(|| format!("Failed to list pods for deployment {deployment} in {ns}"))?;
|
||||
|
||||
for pod in &pod_list.items {
|
||||
if let Some(status) = &pod.status {
|
||||
let phase = status.phase.as_deref().unwrap_or("");
|
||||
if phase == "Running" {
|
||||
if let Some(name) = &pod.metadata.name {
|
||||
return Ok(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try with app= label
|
||||
let label2 = format!("app={deployment}");
|
||||
let lp2 = kube::api::ListParams::default().labels(&label2);
|
||||
let pod_list2 = match pods.list(&lp2).await {
|
||||
Ok(list) => list,
|
||||
Err(_) => {
|
||||
return Err(SunbeamError::kube(format!(
|
||||
"No running pod found for deployment {deployment} in {ns}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
for pod in &pod_list2.items {
|
||||
if let Some(status) = &pod.status {
|
||||
let phase = status.phase.as_deref().unwrap_or("");
|
||||
if phase == "Running" {
|
||||
if let Some(name) = &pod.metadata.name {
|
||||
return Ok(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(SunbeamError::kube(format!(
|
||||
"No running pod found for deployment {deployment} in {ns}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Create a mailbox in Messages via kubectl exec into the backend.
|
||||
async fn create_mailbox(email: &str, name: &str) {
|
||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
warn(&format!("Invalid email for mailbox creation: {email}"));
|
||||
return;
|
||||
}
|
||||
let local_part = parts[0];
|
||||
let domain_part = parts[1];
|
||||
let display_name = if name.is_empty() { local_part } else { name };
|
||||
let _ = display_name; // used in Python for future features; kept for parity
|
||||
|
||||
step(&format!("Creating mailbox: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "messages-backend").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find messages-backend pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
"mb, created = Mailbox.objects.get_or_create(\n local_part=\"{}\",\n domain=MailDomain.objects.get(name=\"{}\"),\n)\nprint(\"created\" if created else \"exists\")\n",
|
||||
local_part, domain_part,
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await {
|
||||
Ok((0, output)) if output.contains("created") => {
|
||||
ok(&format!("Mailbox {email} created."));
|
||||
}
|
||||
Ok((0, output)) if output.contains("exists") => {
|
||||
ok(&format!("Mailbox {email} already exists."));
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!(
|
||||
"Could not create mailbox (Messages backend may not be running): {output}"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not create mailbox: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a mailbox and associated Django user in Messages.
|
||||
async fn delete_mailbox(email: &str) {
|
||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
warn(&format!("Invalid email for mailbox deletion: {email}"));
|
||||
return;
|
||||
}
|
||||
let local_part = parts[0];
|
||||
let domain_part = parts[1];
|
||||
|
||||
step(&format!("Cleaning up mailbox: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "messages-backend").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find messages-backend pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
"from django.contrib.auth import get_user_model\nUser = get_user_model()\ndeleted = 0\nfor mb in Mailbox.objects.filter(local_part=\"{local_part}\", domain__name=\"{domain_part}\"):\n mb.delete()\n deleted += 1\ntry:\n u = User.objects.get(email=\"{email}\")\n u.delete()\n deleted += 1\nexcept User.DoesNotExist:\n pass\nprint(f\"deleted {{deleted}}\")\n",
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await {
|
||||
Ok((0, output)) if output.contains("deleted") => {
|
||||
ok("Mailbox and user cleaned up.");
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!("Could not clean up mailbox: {output}"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not clean up mailbox: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Projects (Planka) user and add them as manager of the Default project.
|
||||
async fn setup_projects_user(email: &str, name: &str) {
|
||||
step(&format!("Setting up Projects user: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "projects").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find projects pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let js = format!(
|
||||
"const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n let user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{\n const id = Date.now().toString();\n await knex('user_account').insert({{\n id, email: '{email}', name: '{name}', password: '',\n is_admin: true, is_sso: true, language: 'en-US',\n created_at: new Date(), updated_at: new Date()\n }});\n user = {{id}};\n console.log('user_created');\n }} else {{\n console.log('user_exists');\n }}\n const project = await knex('project').where({{name: 'Default'}}).first();\n if (project) {{\n const exists = await knex('project_manager').where({{project_id: project.id, user_id: user.id}}).first();\n if (!exists) {{\n await knex('project_manager').insert({{\n id: (Date.now()+1).toString(), project_id: project.id,\n user_id: user.id, created_at: new Date()\n }});\n console.log('manager_added');\n }} else {{\n console.log('manager_exists');\n }}\n }} else {{\n console.log('no_default_project');\n }}\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n",
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["node", "-e", &js];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await {
|
||||
Ok((0, output))
|
||||
if output.contains("manager_added") || output.contains("manager_exists") =>
|
||||
{
|
||||
ok("Projects user ready.");
|
||||
}
|
||||
Ok((0, output)) if output.contains("no_default_project") => {
|
||||
warn("No Default project found in Projects -- skip.");
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!("Could not set up Projects user: {output}"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not set up Projects user: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a user from Projects (Planka) -- delete memberships and soft-delete user.
|
||||
async fn cleanup_projects_user(email: &str) {
|
||||
step(&format!("Cleaning up Projects user: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "projects").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find projects pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let js = format!(
|
||||
"const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n const user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{ console.log('not_found'); return; }}\n await knex('board_membership').where({{user_id: user.id}}).del();\n await knex('project_manager').where({{user_id: user.id}}).del();\n await knex('user_account').where({{id: user.id}}).update({{deleted_at: new Date()}});\n console.log('cleaned');\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n",
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["node", "-e", &js];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await {
|
||||
Ok((0, output)) if output.contains("cleaned") => {
|
||||
ok("Projects user cleaned up.");
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!("Could not clean up Projects user: {output}"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not clean up Projects user: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Send a welcome email via cluster Postfix (port-forward to svc/postfix in lasuite).
|
||||
/// Send a welcome email via cluster Stalwart (port-forward to svc/stalwart in stalwart ns).
|
||||
async fn send_welcome_email(
|
||||
domain: &str,
|
||||
email: &str,
|
||||
@@ -262,11 +57,7 @@ After that, head to https://auth.{domain}/settings to set up your
|
||||
profile -- add your name, profile picture, and any other details.
|
||||
|
||||
Your services:
|
||||
Calendar: https://cal.{domain}
|
||||
Drive: https://drive.{domain}
|
||||
Mail: https://mail.{domain}
|
||||
Meet: https://meet.{domain}
|
||||
Projects: https://projects.{domain}
|
||||
Source Code: https://src.{domain}
|
||||
|
||||
Messages (Matrix):
|
||||
@@ -296,7 +87,7 @@ Messages (Matrix):
|
||||
.body(body_text)
|
||||
.ctx("Failed to build email message")?;
|
||||
|
||||
let _pf = PortForward::new("lasuite", "postfix", SMTP_LOCAL_PORT, 25).await?;
|
||||
let _pf = PortForward::new("stalwart", "stalwart", SMTP_LOCAL_PORT, 25).await?;
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||
.port(SMTP_LOCAL_PORT)
|
||||
@@ -331,7 +122,7 @@ pub async fn cmd_user_onboard(
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
|
||||
let (iid, recovery_link, recovery_code, is_new) = {
|
||||
let (iid, recovery_link, recovery_code, _is_new) = {
|
||||
let existing = find_identity(&pf.base_url, email, false).await?;
|
||||
|
||||
if let Some(existing) = existing {
|
||||
@@ -412,12 +203,6 @@ pub async fn cmd_user_onboard(
|
||||
|
||||
drop(pf);
|
||||
|
||||
// Provision app-level accounts for new users
|
||||
if is_new {
|
||||
create_mailbox(email, name).await;
|
||||
setup_projects_user(email, name).await;
|
||||
}
|
||||
|
||||
if send_email {
|
||||
let domain = crate::kube::get_domain().await?;
|
||||
let recipient = if notify.is_empty() { email } else { notify };
|
||||
@@ -498,19 +283,8 @@ pub async fn cmd_user_offboard(target: &str) -> Result<()> {
|
||||
|
||||
drop(pf);
|
||||
|
||||
// Clean up Messages mailbox and Projects user
|
||||
let email = identity
|
||||
.get("traits")
|
||||
.and_then(|t| t.get("email"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !email.is_empty() {
|
||||
delete_mailbox(email).await;
|
||||
cleanup_projects_user(email).await;
|
||||
}
|
||||
|
||||
ok(&format!("Offboarding complete for {}...", short_id(&iid)));
|
||||
warn("Existing access tokens expire within ~1h (Hydra TTL).");
|
||||
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE (~1h).");
|
||||
warn("App sessions expire within SESSION_COOKIE_AGE (~1h).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user