refactor(sdk): remove lasuite from sunbeam-sdk

This commit is contained in:
2026-04-07 19:26:11 +01:00
parent db925a54a6
commit 0c55be8d13
26 changed files with 54 additions and 5597 deletions

View File

@@ -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]

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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",
];

View File

@@ -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)) => {

View File

@@ -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(())
}

View File

@@ -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}"
);
}
}
}

View File

@@ -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"))
}

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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"
);
}

View File

@@ -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"
}),

View File

@@ -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]

View File

@@ -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