refactor(sdk): remove lasuite from sunbeam-sdk
This commit is contained in:
@@ -17,10 +17,9 @@ opensearch = []
|
|||||||
s3 = []
|
s3 = []
|
||||||
livekit = []
|
livekit = []
|
||||||
monitoring = []
|
monitoring = []
|
||||||
lasuite = []
|
|
||||||
build = []
|
build = []
|
||||||
cli = ["dep:clap"]
|
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"]
|
integration = ["all"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -161,16 +161,6 @@ fn check_registry() -> Vec<CheckEntry> {
|
|||||||
ns: "ory",
|
ns: "ory",
|
||||||
svc: "hydra",
|
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 {
|
CheckEntry {
|
||||||
func: |d, c| Box::pin(check_livekit(d, c)),
|
func: |d, c| Box::pin(check_livekit(d, c)),
|
||||||
ns: "media",
|
ns: "media",
|
||||||
@@ -372,9 +362,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_check_registry_has_all_checks() {
|
fn test_check_registry_has_all_checks() {
|
||||||
let registry = check_registry();
|
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].ns, "devtools");
|
||||||
assert_eq!(registry[0].svc, "gitea");
|
assert_eq!(registry[0].svc, "gitea");
|
||||||
assert_eq!(registry[1].ns, "devtools");
|
assert_eq!(registry[1].ns, "devtools");
|
||||||
@@ -391,12 +381,8 @@ mod tests {
|
|||||||
assert_eq!(registry[6].svc, "kratos");
|
assert_eq!(registry[6].svc, "kratos");
|
||||||
assert_eq!(registry[7].ns, "ory");
|
assert_eq!(registry[7].ns, "ory");
|
||||||
assert_eq!(registry[7].svc, "hydra");
|
assert_eq!(registry[7].svc, "hydra");
|
||||||
assert_eq!(registry[8].ns, "lasuite");
|
assert_eq!(registry[8].ns, "media");
|
||||||
assert_eq!(registry[8].svc, "people");
|
assert_eq!(registry[8].svc, "livekit");
|
||||||
assert_eq!(registry[9].ns, "lasuite");
|
|
||||||
assert_eq!(registry[9].svc, "people");
|
|
||||||
assert_eq!(registry[10].ns, "media");
|
|
||||||
assert_eq!(registry[10].svc, "livekit");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -516,12 +502,13 @@ mod tests {
|
|||||||
let registry = check_registry();
|
let registry = check_registry();
|
||||||
let namespaces: std::collections::HashSet<&str> =
|
let namespaces: std::collections::HashSet<&str> =
|
||||||
registry.iter().map(|e| e.ns).collect();
|
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!(
|
assert!(
|
||||||
namespaces.contains(expected),
|
namespaces.contains(expected),
|
||||||
"registry missing namespace: {expected}"
|
"registry missing namespace: {expected}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert!(!namespaces.contains("lasuite"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -531,13 +518,14 @@ mod tests {
|
|||||||
registry.iter().map(|e| e.svc).collect();
|
registry.iter().map(|e| e.svc).collect();
|
||||||
for expected in &[
|
for expected in &[
|
||||||
"gitea", "postgres", "valkey", "openbao", "seaweedfs", "kratos", "hydra",
|
"gitea", "postgres", "valkey", "openbao", "seaweedfs", "kratos", "hydra",
|
||||||
"people", "livekit",
|
"livekit",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
services.contains(expected),
|
services.contains(expected),
|
||||||
"registry missing service: {expected}"
|
"registry missing service: {expected}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert!(!services.contains("people"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -550,16 +538,6 @@ mod tests {
|
|||||||
assert_eq!(gitea.len(), 2);
|
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]
|
#[test]
|
||||||
fn test_check_registry_data_has_three_entries() {
|
fn test_check_registry_data_has_three_entries() {
|
||||||
let registry = check_registry();
|
let registry = check_registry();
|
||||||
@@ -585,7 +563,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_no_target_runs_all() {
|
fn test_no_target_runs_all() {
|
||||||
let selected = filter_registry(None, None);
|
let selected = filter_registry(None, None);
|
||||||
assert_eq!(selected.len(), 11);
|
assert_eq!(selected.len(), 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -616,13 +594,6 @@ mod tests {
|
|||||||
assert_eq!(selected[0], ("ory", "hydra"));
|
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]
|
#[test]
|
||||||
fn test_filter_nonexistent_ns_returns_empty() {
|
fn test_filter_nonexistent_ns_returns_empty() {
|
||||||
let selected = filter_registry(Some("nonexistent"), None);
|
let selected = filter_registry(Some("nonexistent"), None);
|
||||||
|
|||||||
@@ -349,36 +349,6 @@ pub(super) async fn check_hydra_oidc(domain: &str, client: &reqwest::Client) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET https://people.{domain}/ -> any response < 500 (302 to OIDC is fine).
|
|
||||||
pub(super) async fn check_people(domain: &str, client: &reqwest::Client) -> CheckResult {
|
|
||||||
let url = format!("https://people.{domain}/");
|
|
||||||
match http_get(client, &url, None).await {
|
|
||||||
Ok((status, _)) => CheckResult {
|
|
||||||
name: "people".into(),
|
|
||||||
ns: "lasuite".into(),
|
|
||||||
svc: "people".into(),
|
|
||||||
passed: status < 500,
|
|
||||||
detail: format!("HTTP {status}"),
|
|
||||||
},
|
|
||||||
Err(e) => CheckResult::fail("people", "lasuite", "people", &e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/v1.0/config/ -> any response < 500 (401 auth-required is fine).
|
|
||||||
pub(super) async fn check_people_api(domain: &str, client: &reqwest::Client) -> CheckResult {
|
|
||||||
let url = format!("https://people.{domain}/api/v1.0/config/");
|
|
||||||
match http_get(client, &url, None).await {
|
|
||||||
Ok((status, _)) => CheckResult {
|
|
||||||
name: "people-api".into(),
|
|
||||||
ns: "lasuite".into(),
|
|
||||||
svc: "people".into(),
|
|
||||||
passed: status < 500,
|
|
||||||
detail: format!("HTTP {status}"),
|
|
||||||
},
|
|
||||||
Err(e) => CheckResult::fail("people-api", "lasuite", "people", &e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0.
|
/// kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0.
|
||||||
pub(super) async fn check_livekit(_domain: &str, _client: &reqwest::Client) -> CheckResult {
|
pub(super) async fn check_livekit(_domain: &str, _client: &reqwest::Client) -> CheckResult {
|
||||||
let kube_client = match get_client().await {
|
let kube_client = match get_client().await {
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ impl HttpTransport {
|
|||||||
/// constructing on first call via [`OnceCell`] (async-aware).
|
/// constructing on first call via [`OnceCell`] (async-aware).
|
||||||
///
|
///
|
||||||
/// Auth is resolved per-client:
|
/// 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
|
/// - Gitea PAT (`get_gitea_token()`) — Gitea
|
||||||
/// - None — Prometheus, Loki, S3, LiveKit
|
/// - None — Prometheus, Loki, S3, LiveKit
|
||||||
pub struct SunbeamClient {
|
pub struct SunbeamClient {
|
||||||
@@ -260,20 +260,6 @@ pub struct SunbeamClient {
|
|||||||
loki: OnceCell<crate::monitoring::LokiClient>,
|
loki: OnceCell<crate::monitoring::LokiClient>,
|
||||||
#[cfg(feature = "monitoring")]
|
#[cfg(feature = "monitoring")]
|
||||||
grafana: OnceCell<crate::monitoring::GrafanaClient>,
|
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>,
|
bao: OnceCell<crate::openbao::BaoClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,20 +289,6 @@ impl SunbeamClient {
|
|||||||
loki: OnceCell::new(),
|
loki: OnceCell::new(),
|
||||||
#[cfg(feature = "monitoring")]
|
#[cfg(feature = "monitoring")]
|
||||||
grafana: OnceCell::new(),
|
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(),
|
bao: OnceCell::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,70 +405,6 @@ impl SunbeamClient {
|
|||||||
}).await
|
}).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> {
|
pub async fn bao(&self) -> Result<&crate::openbao::BaoClient> {
|
||||||
self.bao.get_or_try_init(|| async {
|
self.bao.get_or_try_init(|| async {
|
||||||
let url = format!("https://vault.{}", self.domain);
|
let url = format!("https://vault.{}", self.domain);
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
pub const GITEA_ADMIN_USER: &str = "gitea_admin";
|
pub const GITEA_ADMIN_USER: &str = "gitea_admin";
|
||||||
|
|
||||||
|
/// Deprecated: prefer `registry::discover()` → `ServiceRegistry::namespaces()`.
|
||||||
pub const MANAGED_NS: &[&str] = &[
|
pub const MANAGED_NS: &[&str] = &[
|
||||||
"data",
|
"data",
|
||||||
"devtools",
|
"devtools",
|
||||||
"ingress",
|
"ingress",
|
||||||
"lasuite",
|
|
||||||
"matrix",
|
"matrix",
|
||||||
"media",
|
"media",
|
||||||
"monitoring",
|
"monitoring",
|
||||||
"ory",
|
"ory",
|
||||||
|
"stalwart",
|
||||||
"storage",
|
"storage",
|
||||||
"vault-secrets-operator",
|
"vault-secrets-operator",
|
||||||
|
"vpn",
|
||||||
|
"wfe",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -874,8 +874,8 @@ async fn configure_oidc(pod: &str, _password: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new OIDC auth source
|
// Create new OIDC auth source
|
||||||
let oidc_id = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await;
|
let oidc_id = kube_get_secret_field("devtools", "oidc-gitea", "CLIENT_ID").await;
|
||||||
let oidc_secret = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await;
|
let oidc_secret = kube_get_secret_field("devtools", "oidc-gitea", "CLIENT_SECRET").await;
|
||||||
|
|
||||||
match (oidc_id, oidc_secret) {
|
match (oidc_id, oidc_secret) {
|
||||||
(Ok(oidc_id), Ok(oidc_sec)) => {
|
(Ok(oidc_id), Ok(oidc_sec)) => {
|
||||||
|
|||||||
@@ -1,52 +1,10 @@
|
|||||||
//! Per-service image build functions.
|
//! Per-service image build functions.
|
||||||
|
|
||||||
use crate::error::{Result, ResultExt, SunbeamError};
|
use crate::error::{Result, SunbeamError};
|
||||||
use crate::output::{ok, step, warn};
|
use crate::output::step;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::{build_image, deploy_rollout, get_build_env};
|
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<()> {
|
pub async fn build_proxy(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
|
||||||
let env = get_build_env().await?;
|
let env = get_build_env().await?;
|
||||||
let proxy_dir = crate::config::get_repo_root().join("proxy");
|
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(())
|
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<()> {
|
pub async fn build_kratos_admin(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
|
||||||
let env = get_build_env().await?;
|
let env = get_build_env().await?;
|
||||||
let kratos_admin_dir = crate::config::get_repo_root().join("kratos-admin");
|
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(())
|
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
|
// TODO: first deploy requires registration enabled on tuwunel to create
|
||||||
// the @sol:sunbeam.pt bot account. Flow:
|
// the @sol:sunbeam.pt bot account.
|
||||||
// 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>`.
|
|
||||||
pub async fn build_sol(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
|
pub async fn build_sol(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
|
||||||
let env = get_build_env().await?;
|
let env = get_build_env().await?;
|
||||||
let sol_dir = crate::config::get_repo_root().join("sol");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build_calendars(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
|
|
||||||
let env = get_build_env().await?;
|
|
||||||
let cal_dir = crate::config::get_repo_root().join("calendars");
|
|
||||||
if !cal_dir.is_dir() {
|
|
||||||
return Err(SunbeamError::build(format!("calendars source not found at {}", cal_dir.display())));
|
|
||||||
}
|
|
||||||
|
|
||||||
let backend_dir = cal_dir.join("src").join("backend");
|
|
||||||
let backend_image = format!("{}/studio/calendars-backend:latest", env.registry);
|
|
||||||
step(&format!("Building calendars-backend -> {backend_image} ..."));
|
|
||||||
|
|
||||||
// Stage translations.json into the build context
|
|
||||||
let translations_src = cal_dir
|
|
||||||
.join("src")
|
|
||||||
.join("frontend")
|
|
||||||
.join("apps")
|
|
||||||
.join("calendars")
|
|
||||||
.join("src")
|
|
||||||
.join("features")
|
|
||||||
.join("i18n")
|
|
||||||
.join("translations.json");
|
|
||||||
|
|
||||||
let translations_dst = backend_dir.join("_translations.json");
|
|
||||||
let mut cleanup: Vec<PathBuf> = Vec::new();
|
|
||||||
let mut dockerfile = backend_dir.join("Dockerfile");
|
|
||||||
|
|
||||||
if translations_src.exists() {
|
|
||||||
std::fs::copy(&translations_src, &translations_dst)?;
|
|
||||||
cleanup.push(translations_dst);
|
|
||||||
|
|
||||||
// Patch Dockerfile to COPY translations into production image
|
|
||||||
let mut content = std::fs::read_to_string(&dockerfile)?;
|
|
||||||
content.push_str(
|
|
||||||
"\n# Sunbeam: bake translations.json for default calendar names\n\
|
|
||||||
COPY _translations.json /data/translations.json\n",
|
|
||||||
);
|
|
||||||
let patched_df = backend_dir.join("Dockerfile._sunbeam_patched");
|
|
||||||
std::fs::write(&patched_df, content)?;
|
|
||||||
cleanup.push(patched_df.clone());
|
|
||||||
dockerfile = patched_df;
|
|
||||||
}
|
|
||||||
|
|
||||||
build_image(
|
|
||||||
&env,
|
|
||||||
&backend_image,
|
|
||||||
&dockerfile,
|
|
||||||
&backend_dir,
|
|
||||||
Some("backend-production"),
|
|
||||||
None,
|
|
||||||
push,
|
|
||||||
no_cache,
|
|
||||||
&cleanup,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// caldav
|
|
||||||
let caldav_image = format!("{}/studio/calendars-caldav:latest", env.registry);
|
|
||||||
step(&format!("Building calendars-caldav -> {caldav_image} ..."));
|
|
||||||
let caldav_dir = cal_dir.join("src").join("caldav");
|
|
||||||
build_image(
|
|
||||||
&env,
|
|
||||||
&caldav_image,
|
|
||||||
&caldav_dir.join("Dockerfile"),
|
|
||||||
&caldav_dir,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
push,
|
|
||||||
no_cache,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// frontend
|
|
||||||
let frontend_image = format!("{}/studio/calendars-frontend:latest", env.registry);
|
|
||||||
step(&format!(
|
|
||||||
"Building calendars-frontend -> {frontend_image} ..."
|
|
||||||
));
|
|
||||||
let integration_base = format!("https://integration.{}", env.domain);
|
|
||||||
let mut build_args = HashMap::new();
|
|
||||||
build_args.insert(
|
|
||||||
"VISIO_BASE_URL".to_string(),
|
|
||||||
format!("https://meet.{}", env.domain),
|
|
||||||
);
|
|
||||||
build_args.insert(
|
|
||||||
"GAUFRE_WIDGET_PATH".to_string(),
|
|
||||||
format!("{integration_base}/api/v2/lagaufre.js"),
|
|
||||||
);
|
|
||||||
build_args.insert(
|
|
||||||
"GAUFRE_API_URL".to_string(),
|
|
||||||
format!("{integration_base}/api/v2/services.json"),
|
|
||||||
);
|
|
||||||
build_args.insert(
|
|
||||||
"THEME_CSS_URL".to_string(),
|
|
||||||
format!("{integration_base}/api/v2/theme.css"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let frontend_dir = cal_dir.join("src").join("frontend");
|
|
||||||
build_image(
|
|
||||||
&env,
|
|
||||||
&frontend_image,
|
|
||||||
&frontend_dir.join("Dockerfile"),
|
|
||||||
&frontend_dir,
|
|
||||||
Some("frontend-production"),
|
|
||||||
Some(&build_args),
|
|
||||||
push,
|
|
||||||
no_cache,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if deploy {
|
|
||||||
deploy_rollout(
|
|
||||||
&env,
|
|
||||||
&[
|
|
||||||
"calendars-backend",
|
|
||||||
"calendars-worker",
|
|
||||||
"calendars-caldav",
|
|
||||||
"calendars-frontend",
|
|
||||||
],
|
|
||||||
"lasuite",
|
|
||||||
180,
|
|
||||||
Some(&[backend_image, caldav_image, frontend_image]),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,22 +19,8 @@ use crate::output::{ok, step, warn};
|
|||||||
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
|
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
|
||||||
pub enum BuildTarget {
|
pub enum BuildTarget {
|
||||||
Proxy,
|
Proxy,
|
||||||
Integration,
|
|
||||||
KratosAdmin,
|
KratosAdmin,
|
||||||
Meet,
|
|
||||||
DocsFrontend,
|
|
||||||
PeopleFrontend,
|
|
||||||
People,
|
|
||||||
Messages,
|
|
||||||
MessagesBackend,
|
|
||||||
MessagesFrontend,
|
|
||||||
MessagesMtaIn,
|
|
||||||
MessagesMtaOut,
|
|
||||||
MessagesMpa,
|
|
||||||
MessagesSocksProxy,
|
|
||||||
Tuwunel,
|
Tuwunel,
|
||||||
Calendars,
|
|
||||||
Projects,
|
|
||||||
Sol,
|
Sol,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,22 +28,8 @@ impl std::fmt::Display for BuildTarget {
|
|||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
let s = match self {
|
let s = match self {
|
||||||
BuildTarget::Proxy => "proxy",
|
BuildTarget::Proxy => "proxy",
|
||||||
BuildTarget::Integration => "integration",
|
|
||||||
BuildTarget::KratosAdmin => "kratos-admin",
|
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::Tuwunel => "tuwunel",
|
||||||
BuildTarget::Calendars => "calendars",
|
|
||||||
BuildTarget::Projects => "projects",
|
|
||||||
BuildTarget::Sol => "sol",
|
BuildTarget::Sol => "sol",
|
||||||
};
|
};
|
||||||
write!(f, "{s}")
|
write!(f, "{s}")
|
||||||
@@ -65,38 +37,7 @@ impl std::fmt::Display for BuildTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// amd64-only images that need mirroring: (source, org, repo, tag).
|
/// amd64-only images that need mirroring: (source, org, repo, tag).
|
||||||
const AMD64_ONLY_IMAGES: &[(&str, &str, &str, &str)] = &[
|
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",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Build environment
|
// 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<()> {
|
pub async fn cmd_build(what: &BuildTarget, push: bool, deploy: bool, no_cache: bool) -> Result<()> {
|
||||||
match what {
|
match what {
|
||||||
BuildTarget::Proxy => builders::build_proxy(push, deploy, no_cache).await,
|
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::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::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,
|
BuildTarget::Sol => builders::build_sol(push, deploy, no_cache).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,41 +866,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn amd64_only_images_all_from_docker_hub() {
|
fn amd64_only_images_empty_after_lasuite_removal() {
|
||||||
for (src, _org, _repo, _tag) in AMD64_ONLY_IMAGES {
|
assert!(AMD64_ONLY_IMAGES.is_empty());
|
||||||
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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1007,22 +884,8 @@ mod tests {
|
|||||||
fn build_target_display_all_lowercase_or_hyphenated() {
|
fn build_target_display_all_lowercase_or_hyphenated() {
|
||||||
let targets = [
|
let targets = [
|
||||||
BuildTarget::Proxy,
|
BuildTarget::Proxy,
|
||||||
BuildTarget::Integration,
|
|
||||||
BuildTarget::KratosAdmin,
|
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::Tuwunel,
|
||||||
BuildTarget::Calendars,
|
|
||||||
BuildTarget::Projects,
|
|
||||||
BuildTarget::Sol,
|
BuildTarget::Sol,
|
||||||
];
|
];
|
||||||
for t in &targets {
|
for t in &targets {
|
||||||
@@ -1039,32 +902,4 @@ mod tests {
|
|||||||
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
|
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn messages_components_non_empty() {
|
|
||||||
assert!(!builders::MESSAGES_COMPONENTS.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn messages_components_dockerfiles_are_relative() {
|
|
||||||
for (_name, _image, dockerfile_rel, _target) in builders::MESSAGES_COMPONENTS {
|
|
||||||
assert!(
|
|
||||||
dockerfile_rel.ends_with("Dockerfile"),
|
|
||||||
"Expected Dockerfile suffix in: {dockerfile_rel}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!dockerfile_rel.starts_with('/'),
|
|
||||||
"Dockerfile path should be relative: {dockerfile_rel}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn messages_components_names_match_build_targets() {
|
|
||||||
for (name, _image, _df, _target) in builders::MESSAGES_COMPONENTS {
|
|
||||||
assert!(
|
|
||||||
name.starts_with("messages-"),
|
|
||||||
"Component name should start with 'messages-': {name}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ pub async fn kube_rollout_restart(ns: &str, deployment: &str) -> Result<()> {
|
|||||||
/// Discover the active domain from cluster state.
|
/// Discover the active domain from cluster state.
|
||||||
///
|
///
|
||||||
/// Tries the gitea-inline-config secret first (DOMAIN=src.<domain>),
|
/// 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)]
|
#[allow(dead_code)]
|
||||||
pub async fn get_domain() -> Result<String> {
|
pub async fn get_domain() -> Result<String> {
|
||||||
// 1. Gitea inline-config secret
|
// 1. Gitea inline-config secret
|
||||||
@@ -426,25 +426,7 @@ pub async fn get_domain() -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: lasuite-oidc-provider configmap
|
// 2. Local dev fallback: Lima VM IP
|
||||||
{
|
|
||||||
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
|
|
||||||
let ip = get_lima_ip().await;
|
let ip = get_lima_ip().await;
|
||||||
Ok(format!("{ip}.sslip.io"))
|
Ok(format!("{ip}.sslip.io"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
//! Calendars service client — calendars, events, RSVP.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite Calendars API.
|
|
||||||
pub struct CalendarsClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for CalendarsClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"calendars"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalendarsClient {
|
|
||||||
/// Build a CalendarsClient from domain (e.g. `https://calendar.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://calendar.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Calendars ----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List calendars.
|
|
||||||
pub async fn list_calendars(&self) -> Result<DRFPage<Calendar>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
"calendars/",
|
|
||||||
Option::<&()>::None,
|
|
||||||
"calendars list calendars",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single calendar by ID.
|
|
||||||
pub async fn get_calendar(&self, id: &str) -> Result<Calendar> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("calendars/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"calendars get calendar",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new calendar.
|
|
||||||
pub async fn create_calendar(&self, body: &serde_json::Value) -> Result<Calendar> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "calendars/", Some(body), "calendars create calendar")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Events -------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List events in a calendar.
|
|
||||||
pub async fn list_events(&self, calendar_id: &str) -> Result<DRFPage<CalEvent>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("calendars/{calendar_id}/events/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"calendars list events",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single event.
|
|
||||||
pub async fn get_event(
|
|
||||||
&self,
|
|
||||||
calendar_id: &str,
|
|
||||||
event_id: &str,
|
|
||||||
) -> Result<CalEvent> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("calendars/{calendar_id}/events/{event_id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"calendars get event",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new event in a calendar.
|
|
||||||
pub async fn create_event(
|
|
||||||
&self,
|
|
||||||
calendar_id: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<CalEvent> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("calendars/{calendar_id}/events/"),
|
|
||||||
Some(body),
|
|
||||||
"calendars create event",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update an event (partial).
|
|
||||||
pub async fn update_event(
|
|
||||||
&self,
|
|
||||||
calendar_id: &str,
|
|
||||||
event_id: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<CalEvent> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!("calendars/{calendar_id}/events/{event_id}/"),
|
|
||||||
Some(body),
|
|
||||||
"calendars update event",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an event.
|
|
||||||
pub async fn delete_event(
|
|
||||||
&self,
|
|
||||||
calendar_id: &str,
|
|
||||||
event_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
self.transport
|
|
||||||
.send(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!("calendars/{calendar_id}/events/{event_id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"calendars delete event",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RSVP to an event.
|
|
||||||
pub async fn rsvp(
|
|
||||||
&self,
|
|
||||||
calendar_id: &str,
|
|
||||||
event_id: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<()> {
|
|
||||||
self.transport
|
|
||||||
.send(
|
|
||||||
Method::POST,
|
|
||||||
&format!("calendars/{calendar_id}/events/{event_id}/rsvp/"),
|
|
||||||
Some(body),
|
|
||||||
"calendars rsvp",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = CalendarsClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://calendar.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "calendars");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = CalendarsClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,170 +0,0 @@
|
|||||||
//! Docs service client — documents, templates, versions, invitations.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite Docs API.
|
|
||||||
pub struct DocsClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for DocsClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"docs"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocsClient {
|
|
||||||
/// Build a DocsClient from domain (e.g. `https://docs.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://docs.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Documents ----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List documents with optional pagination.
|
|
||||||
pub async fn list_documents(&self, page: Option<u32>) -> Result<DRFPage<Document>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("documents/?page={p}"),
|
|
||||||
None => "documents/".to_string(),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "docs list documents")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single document by ID.
|
|
||||||
pub async fn get_document(&self, id: &str) -> Result<Document> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("documents/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"docs get document",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new document.
|
|
||||||
pub async fn create_document(&self, body: &serde_json::Value) -> Result<Document> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "documents/", Some(body), "docs create document")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a document (partial).
|
|
||||||
pub async fn update_document(&self, id: &str, body: &serde_json::Value) -> Result<Document> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!("documents/{id}/"),
|
|
||||||
Some(body),
|
|
||||||
"docs update document",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a document.
|
|
||||||
pub async fn delete_document(&self, id: &str) -> Result<()> {
|
|
||||||
self.transport
|
|
||||||
.send(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!("documents/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"docs delete document",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Templates ----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List templates with optional pagination.
|
|
||||||
pub async fn list_templates(&self, page: Option<u32>) -> Result<DRFPage<Template>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("templates/?page={p}"),
|
|
||||||
None => "templates/".to_string(),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "docs list templates")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new template.
|
|
||||||
pub async fn create_template(&self, body: &serde_json::Value) -> Result<Template> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "templates/", Some(body), "docs create template")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Versions -----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List versions of a document.
|
|
||||||
pub async fn list_versions(&self, doc_id: &str) -> Result<DRFPage<DocVersion>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("documents/{doc_id}/versions/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"docs list versions",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Invitations --------------------------------------------------------
|
|
||||||
|
|
||||||
/// Invite a user to collaborate on a document.
|
|
||||||
pub async fn invite_user(
|
|
||||||
&self,
|
|
||||||
doc_id: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<Invitation> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("documents/{doc_id}/invitations/"),
|
|
||||||
Some(body),
|
|
||||||
"docs invite user",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = DocsClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://docs.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "docs");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = DocsClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
//! Drive service client — files, folders, shares, permissions.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite Drive API.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct DriveClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for DriveClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"drive"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DriveClient {
|
|
||||||
/// Build a DriveClient from domain (e.g. `https://drive.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://drive.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Items --------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List items with optional pagination and type filter.
|
|
||||||
pub async fn list_items(
|
|
||||||
&self,
|
|
||||||
page: Option<u32>,
|
|
||||||
item_type: Option<&str>,
|
|
||||||
) -> Result<DRFPage<DriveFile>> {
|
|
||||||
let mut path = String::from("items/?");
|
|
||||||
if let Some(p) = page {
|
|
||||||
path.push_str(&format!("page={p}&"));
|
|
||||||
}
|
|
||||||
if let Some(t) = item_type {
|
|
||||||
path.push_str(&format!("type={t}&"));
|
|
||||||
}
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "drive list items")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List files (items with type=file).
|
|
||||||
pub async fn list_files(&self, page: Option<u32>) -> Result<DRFPage<DriveFile>> {
|
|
||||||
self.list_items(page, Some("file")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List folders (items with type=folder).
|
|
||||||
pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> {
|
|
||||||
let mut path = String::from("items/?type=folder&");
|
|
||||||
if let Some(p) = page {
|
|
||||||
path.push_str(&format!("page={p}&"));
|
|
||||||
}
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "drive list folders")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single item by ID.
|
|
||||||
pub async fn get_file(&self, id: &str) -> Result<DriveFile> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("items/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"drive get item",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new item (file or folder) at the root level.
|
|
||||||
pub async fn upload_file(&self, body: &serde_json::Value) -> Result<DriveFile> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "items/", Some(body), "drive create item")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an item.
|
|
||||||
pub async fn delete_file(&self, id: &str) -> Result<()> {
|
|
||||||
self.transport
|
|
||||||
.send(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!("items/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"drive delete item",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new folder at the root level.
|
|
||||||
pub async fn create_folder(&self, body: &serde_json::Value) -> Result<DriveFolder> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "items/", Some(body), "drive create folder")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Items (children API) ------------------------------------------------
|
|
||||||
|
|
||||||
/// Create a child item under a parent folder.
|
|
||||||
/// Returns the created item including its upload_url for files.
|
|
||||||
pub async fn create_child(
|
|
||||||
&self,
|
|
||||||
parent_id: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("items/{parent_id}/children/"),
|
|
||||||
Some(body),
|
|
||||||
"drive create child",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List children of an item (folder).
|
|
||||||
pub async fn list_children(
|
|
||||||
&self,
|
|
||||||
parent_id: &str,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> Result<DRFPage<serde_json::Value>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("items/{parent_id}/children/?page={p}"),
|
|
||||||
None => format!("items/{parent_id}/children/"),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "drive list children")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify Drive that a file upload to S3 is complete.
|
|
||||||
pub async fn upload_ended(&self, item_id: &str) -> Result<serde_json::Value> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("items/{item_id}/upload-ended/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"drive upload ended",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload file bytes directly to a presigned S3 URL.
|
|
||||||
/// The presigned URL's SigV4 signature covers host + x-amz-acl headers.
|
|
||||||
/// Retries up to 3 times on 502/503/connection errors.
|
|
||||||
pub async fn upload_to_s3(&self, presigned_url: &str, data: bytes::Bytes) -> Result<()> {
|
|
||||||
let max_retries = 3;
|
|
||||||
for attempt in 0..=max_retries {
|
|
||||||
let resp = self.transport.http
|
|
||||||
.put(presigned_url)
|
|
||||||
.header("x-amz-acl", "private")
|
|
||||||
.body(data.clone())
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match resp {
|
|
||||||
Ok(r) if r.status().is_success() => return Ok(()),
|
|
||||||
Ok(r) if (r.status() == 502 || r.status() == 503) && attempt < max_retries => {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Ok(r) => {
|
|
||||||
let status = r.status();
|
|
||||||
let body = r.text().await.unwrap_or_default();
|
|
||||||
return Err(crate::error::SunbeamError::network(format!(
|
|
||||||
"S3 upload: HTTP {status}: {body}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Err(_) if attempt < max_retries => {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(crate::error::SunbeamError::network(format!("S3 upload: {e}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Shares -------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Share a file with a user.
|
|
||||||
pub async fn share_file(&self, id: &str, body: &serde_json::Value) -> Result<FileShare> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("files/{id}/shares/"),
|
|
||||||
Some(body),
|
|
||||||
"drive share file",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Permissions --------------------------------------------------------
|
|
||||||
|
|
||||||
/// Get permissions for a file.
|
|
||||||
pub async fn get_permissions(&self, id: &str) -> Result<DRFPage<FilePermission>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("files/{id}/permissions/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"drive get permissions",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = DriveClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://drive.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "drive");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = DriveClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
//! Find (search) service client.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite Find (search) API.
|
|
||||||
pub struct FindClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for FindClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"find"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FindClient {
|
|
||||||
/// Build a FindClient from domain (e.g. `https://find.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://find.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search across La Suite services.
|
|
||||||
pub async fn search(
|
|
||||||
&self,
|
|
||||||
query: &str,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> Result<DRFPage<SearchResult>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("search/?q={query}&page={p}"),
|
|
||||||
None => format!("search/?q={query}"),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "find search")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = FindClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://find.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "find");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = FindClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
//! Meet service client — rooms and recordings.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite Meet API.
|
|
||||||
pub struct MeetClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for MeetClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"meet"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MeetClient {
|
|
||||||
/// Build a MeetClient from domain (e.g. `https://meet.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://meet.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Rooms --------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List rooms with optional pagination.
|
|
||||||
pub async fn list_rooms(&self, page: Option<u32>) -> Result<DRFPage<MeetRoom>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("rooms/?page={p}"),
|
|
||||||
None => "rooms/".to_string(),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "meet list rooms")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new room.
|
|
||||||
pub async fn create_room(&self, body: &serde_json::Value) -> Result<MeetRoom> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "rooms/", Some(body), "meet create room")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single room by ID.
|
|
||||||
pub async fn get_room(&self, id: &str) -> Result<MeetRoom> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("rooms/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"meet get room",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a room (partial).
|
|
||||||
pub async fn update_room(&self, id: &str, body: &serde_json::Value) -> Result<MeetRoom> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!("rooms/{id}/"),
|
|
||||||
Some(body),
|
|
||||||
"meet update room",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a room.
|
|
||||||
pub async fn delete_room(&self, id: &str) -> Result<()> {
|
|
||||||
self.transport
|
|
||||||
.send(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!("rooms/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"meet delete room",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Recordings ---------------------------------------------------------
|
|
||||||
|
|
||||||
/// List recordings for a room.
|
|
||||||
pub async fn list_recordings(&self, room_id: &str) -> Result<DRFPage<Recording>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("rooms/{room_id}/recordings/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"meet list recordings",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = MeetClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://meet.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "meet");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = MeetClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
//! Messages (mail) service client — mailboxes, messages, folders, contacts.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite Messages (mail) API.
|
|
||||||
pub struct MessagesClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for MessagesClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"messages"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessagesClient {
|
|
||||||
/// Build a MessagesClient from domain (e.g. `https://mail.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://mail.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Mailboxes ----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List mailboxes.
|
|
||||||
pub async fn list_mailboxes(&self) -> Result<DRFPage<Mailbox>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
"mailboxes/",
|
|
||||||
Option::<&()>::None,
|
|
||||||
"messages list mailboxes",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single mailbox by ID.
|
|
||||||
pub async fn get_mailbox(&self, id: &str) -> Result<Mailbox> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("mailboxes/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"messages get mailbox",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Messages -----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List messages in a mailbox folder.
|
|
||||||
pub async fn list_messages(
|
|
||||||
&self,
|
|
||||||
mailbox_id: &str,
|
|
||||||
folder: &str,
|
|
||||||
) -> Result<DRFPage<EmailMessage>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("mailboxes/{mailbox_id}/messages/?folder={folder}"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"messages list messages",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single message.
|
|
||||||
pub async fn get_message(
|
|
||||||
&self,
|
|
||||||
mailbox_id: &str,
|
|
||||||
message_id: &str,
|
|
||||||
) -> Result<EmailMessage> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("mailboxes/{mailbox_id}/messages/{message_id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"messages get message",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message from a mailbox.
|
|
||||||
pub async fn send_message(
|
|
||||||
&self,
|
|
||||||
mailbox_id: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<EmailMessage> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("mailboxes/{mailbox_id}/messages/"),
|
|
||||||
Some(body),
|
|
||||||
"messages send message",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Folders ------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List folders in a mailbox.
|
|
||||||
pub async fn list_folders(&self, mailbox_id: &str) -> Result<DRFPage<MailFolder>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("mailboxes/{mailbox_id}/folders/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"messages list folders",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Contacts -----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List contacts in a mailbox.
|
|
||||||
pub async fn list_contacts(&self, mailbox_id: &str) -> Result<DRFPage<MailContact>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("mailboxes/{mailbox_id}/contacts/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"messages list contacts",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = MessagesClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://mail.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "messages");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = MessagesClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//! La Suite service clients (People, Docs, Meet, Drive, Messages, Calendars, Find).
|
|
||||||
|
|
||||||
pub mod people;
|
|
||||||
pub mod docs;
|
|
||||||
pub mod meet;
|
|
||||||
pub mod drive;
|
|
||||||
pub mod messages;
|
|
||||||
pub mod calendars;
|
|
||||||
pub mod find;
|
|
||||||
pub mod types;
|
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
|
||||||
pub mod cli;
|
|
||||||
|
|
||||||
pub use people::PeopleClient;
|
|
||||||
pub use docs::DocsClient;
|
|
||||||
pub use meet::MeetClient;
|
|
||||||
pub use drive::DriveClient;
|
|
||||||
pub use messages::MessagesClient;
|
|
||||||
pub use calendars::CalendarsClient;
|
|
||||||
pub use find::FindClient;
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
//! People service client — contacts, teams, service providers, mail domains.
|
|
||||||
|
|
||||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
|
||||||
use crate::error::Result;
|
|
||||||
use reqwest::Method;
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Client for the La Suite People API.
|
|
||||||
pub struct PeopleClient {
|
|
||||||
pub(crate) transport: HttpTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServiceClient for PeopleClient {
|
|
||||||
fn service_name(&self) -> &'static str {
|
|
||||||
"people"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
|
||||||
&self.transport.base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
|
||||||
Self {
|
|
||||||
transport: HttpTransport::new(&base_url, auth),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PeopleClient {
|
|
||||||
/// Build a PeopleClient from domain (e.g. `https://people.{domain}/api/v1.0`).
|
|
||||||
pub fn connect(domain: &str) -> Self {
|
|
||||||
let base_url = format!("https://people.{domain}/api/v1.0");
|
|
||||||
Self::from_parts(base_url, AuthMethod::Bearer(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the bearer token for authentication.
|
|
||||||
pub fn with_token(mut self, token: &str) -> Self {
|
|
||||||
self.transport.set_auth(AuthMethod::Bearer(token.to_string()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Contacts -----------------------------------------------------------
|
|
||||||
|
|
||||||
/// List contacts with optional pagination.
|
|
||||||
pub async fn list_contacts(&self, page: Option<u32>) -> Result<DRFPage<Contact>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("contacts/?page={p}"),
|
|
||||||
None => "contacts/".to_string(),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "people list contacts")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single contact by ID.
|
|
||||||
pub async fn get_contact(&self, id: &str) -> Result<Contact> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("contacts/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"people get contact",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new contact.
|
|
||||||
pub async fn create_contact(&self, body: &serde_json::Value) -> Result<Contact> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "contacts/", Some(body), "people create contact")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a contact (partial).
|
|
||||||
pub async fn update_contact(&self, id: &str, body: &serde_json::Value) -> Result<Contact> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!("contacts/{id}/"),
|
|
||||||
Some(body),
|
|
||||||
"people update contact",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a contact.
|
|
||||||
pub async fn delete_contact(&self, id: &str) -> Result<()> {
|
|
||||||
self.transport
|
|
||||||
.send(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!("contacts/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"people delete contact",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Teams --------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List teams with optional pagination.
|
|
||||||
pub async fn list_teams(&self, page: Option<u32>) -> Result<DRFPage<Team>> {
|
|
||||||
let path = match page {
|
|
||||||
Some(p) => format!("teams/?page={p}"),
|
|
||||||
None => "teams/".to_string(),
|
|
||||||
};
|
|
||||||
self.transport
|
|
||||||
.json(Method::GET, &path, Option::<&()>::None, "people list teams")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single team by ID.
|
|
||||||
pub async fn get_team(&self, id: &str) -> Result<Team> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
&format!("teams/{id}/"),
|
|
||||||
Option::<&()>::None,
|
|
||||||
"people get team",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new team.
|
|
||||||
pub async fn create_team(&self, body: &serde_json::Value) -> Result<Team> {
|
|
||||||
self.transport
|
|
||||||
.json(Method::POST, "teams/", Some(body), "people create team")
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Service providers --------------------------------------------------
|
|
||||||
|
|
||||||
/// List service providers.
|
|
||||||
pub async fn list_service_providers(&self) -> Result<DRFPage<ServiceProvider>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
"service-providers/",
|
|
||||||
Option::<&()>::None,
|
|
||||||
"people list service providers",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Mail domains -------------------------------------------------------
|
|
||||||
|
|
||||||
/// List mail domains.
|
|
||||||
pub async fn list_mail_domains(&self) -> Result<DRFPage<MailDomain>> {
|
|
||||||
self.transport
|
|
||||||
.json(
|
|
||||||
Method::GET,
|
|
||||||
"mail-domains/",
|
|
||||||
Option::<&()>::None,
|
|
||||||
"people list mail domains",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_connect_url() {
|
|
||||||
let c = PeopleClient::connect("sunbeam.pt");
|
|
||||||
assert_eq!(c.base_url(), "https://people.sunbeam.pt/api/v1.0");
|
|
||||||
assert_eq!(c.service_name(), "people");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_from_parts() {
|
|
||||||
let c = PeopleClient::from_parts(
|
|
||||||
"http://localhost:8000/api/v1.0".into(),
|
|
||||||
AuthMethod::Bearer("tok".into()),
|
|
||||||
);
|
|
||||||
assert_eq!(c.base_url(), "http://localhost:8000/api/v1.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,533 +0,0 @@
|
|||||||
//! Shared types for La Suite DRF-based services.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DRF paginated response
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Standard Django REST Framework paginated list response.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct DRFPage<T> {
|
|
||||||
#[serde(default)]
|
|
||||||
pub count: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub next: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub previous: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub results: Vec<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// People types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A contact in the People service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Contact {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub first_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub email: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub phone: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub avatar: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub organization: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub job_title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A team in the People service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Team {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub members: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A service provider in the People service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct ServiceProvider {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub base_url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mail domain in the People service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct MailDomain {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub status: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Docs types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A document in the Docs service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Document {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub content: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_public: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A document template.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Template {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub content: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_public: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A document version snapshot.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct DocVersion {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub document_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub version_number: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub content: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An invitation to collaborate on a document.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Invitation {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub email: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub role: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub document_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub status: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Meet types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A meeting room.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct MeetRoom {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub slug: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_public: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub configuration: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A recording of a meeting.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Recording {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub room_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub filename: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Drive types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A file in the Drive service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct DriveFile {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub filename: Option<String>,
|
|
||||||
#[serde(default, rename = "type")]
|
|
||||||
pub item_type: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub size: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub mimetype: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub upload_state: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A folder in the Drive service (same API, type=folder).
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct DriveFolder {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default, rename = "type")]
|
|
||||||
pub item_type: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub numchild: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file sharing record.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct FileShare {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub file_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub user_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub role: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file permission entry.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct FilePermission {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub file_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub user_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub role: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub can_read: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub can_write: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Messages types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A mailbox.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Mailbox {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub email: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An email message.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct EmailMessage {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub subject: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub from_address: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub to_addresses: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cc_addresses: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub body: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_read: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub folder: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mail folder.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct MailFolder {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub message_count: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub unread_count: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mail contact.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct MailContact {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub email: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Calendars types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A calendar.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Calendar {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub color: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_default: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A calendar event.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct CalEvent {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub start: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub end: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub location: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub all_day: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub attendees: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub calendar_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Find types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A search result from the Find service.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct SearchResult {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub source: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub score: Option<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_drf_page_deserialize() {
|
|
||||||
let json = r#"{
|
|
||||||
"count": 2,
|
|
||||||
"next": "https://example.com/api/v1.0/contacts/?page=2",
|
|
||||||
"previous": null,
|
|
||||||
"results": [
|
|
||||||
{"id": "1", "first_name": "Alice"},
|
|
||||||
{"id": "2", "first_name": "Bob"}
|
|
||||||
]
|
|
||||||
}"#;
|
|
||||||
let page: DRFPage<Contact> = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(page.count, 2);
|
|
||||||
assert!(page.next.is_some());
|
|
||||||
assert!(page.previous.is_none());
|
|
||||||
assert_eq!(page.results.len(), 2);
|
|
||||||
assert_eq!(page.results[0].first_name.as_deref(), Some("Alice"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_drf_page_empty() {
|
|
||||||
let json = r#"{"count": 0, "next": null, "previous": null, "results": []}"#;
|
|
||||||
let page: DRFPage<Contact> = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(page.count, 0);
|
|
||||||
assert!(page.results.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_drf_page_defaults() {
|
|
||||||
let json = r#"{"results": []}"#;
|
|
||||||
let page: DRFPage<Document> = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(page.count, 0);
|
|
||||||
assert!(page.next.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_contact_roundtrip() {
|
|
||||||
let c = Contact {
|
|
||||||
id: "abc".into(),
|
|
||||||
first_name: Some("Alice".into()),
|
|
||||||
last_name: Some("Smith".into()),
|
|
||||||
email: Some("alice@example.com".into()),
|
|
||||||
phone: None,
|
|
||||||
avatar: None,
|
|
||||||
organization: None,
|
|
||||||
job_title: None,
|
|
||||||
notes: None,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&c).unwrap();
|
|
||||||
let c2: Contact = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(c2.id, "abc");
|
|
||||||
assert_eq!(c2.first_name.as_deref(), Some("Alice"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_document_defaults() {
|
|
||||||
let json = r#"{"id": "doc-1"}"#;
|
|
||||||
let d: Document = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(d.id, "doc-1");
|
|
||||||
assert!(d.title.is_none());
|
|
||||||
assert!(d.content.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_meet_room_deserialize() {
|
|
||||||
let json = r#"{"id": "room-1", "name": "Standup", "slug": "standup"}"#;
|
|
||||||
let r: MeetRoom = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(r.id, "room-1");
|
|
||||||
assert_eq!(r.name.as_deref(), Some("Standup"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calendar_event_deserialize() {
|
|
||||||
let json = r#"{"id": "ev-1", "title": "Lunch", "start": "2026-01-01T12:00:00Z", "all_day": false}"#;
|
|
||||||
let e: CalEvent = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(e.id, "ev-1");
|
|
||||||
assert_eq!(e.all_day, Some(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_search_result_deserialize() {
|
|
||||||
let json = r#"{"id": "sr-1", "title": "Found it", "score": 0.95}"#;
|
|
||||||
let sr: SearchResult = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(sr.id, "sr-1");
|
|
||||||
assert_eq!(sr.score, Some(0.95));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_email_message_deserialize() {
|
|
||||||
let json = r#"{"id": "msg-1", "subject": "Hello", "to_addresses": ["bob@example.com"]}"#;
|
|
||||||
let m: EmailMessage = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(m.id, "msg-1");
|
|
||||||
assert_eq!(m.to_addresses.as_ref().unwrap().len(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,5 @@ pub mod storage;
|
|||||||
pub mod media;
|
pub mod media;
|
||||||
#[cfg(feature = "monitoring")]
|
#[cfg(feature = "monitoring")]
|
||||||
pub mod monitoring;
|
pub mod monitoring;
|
||||||
#[cfg(feature = "lasuite")]
|
|
||||||
pub mod lasuite;
|
|
||||||
#[cfg(feature = "build")]
|
#[cfg(feature = "build")]
|
||||||
pub mod build;
|
pub mod build;
|
||||||
|
|||||||
@@ -784,23 +784,23 @@ mod tests {
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: meet-config
|
name: stalwart-config
|
||||||
namespace: lasuite
|
namespace: stalwart
|
||||||
data:
|
data:
|
||||||
FOO: bar
|
FOO: bar
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: meet-backend
|
name: stalwart
|
||||||
namespace: lasuite
|
namespace: stalwart
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: lasuite
|
name: stalwart
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
@@ -822,14 +822,14 @@ spec:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_keeps_matching_namespace() {
|
fn test_keeps_matching_namespace() {
|
||||||
let result = filter_by_namespace(MULTI_DOC, "lasuite");
|
let result = filter_by_namespace(MULTI_DOC, "stalwart");
|
||||||
assert!(result.contains("name: meet-config"));
|
assert!(result.contains("name: stalwart-config"));
|
||||||
assert!(result.contains("name: meet-backend"));
|
assert!(result.contains("name: stalwart\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_excludes_other_namespaces() {
|
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("namespace: ingress"));
|
||||||
assert!(!result.contains("name: pingora-config"));
|
assert!(!result.contains("name: pingora-config"));
|
||||||
assert!(!result.contains("name: pingora\n"));
|
assert!(!result.contains("name: pingora\n"));
|
||||||
@@ -837,7 +837,7 @@ spec:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_includes_namespace_resource_itself() {
|
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"));
|
assert!(result.contains("kind: Namespace"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,7 +846,7 @@ spec:
|
|||||||
let result = filter_by_namespace(MULTI_DOC, "ingress");
|
let result = filter_by_namespace(MULTI_DOC, "ingress");
|
||||||
assert!(result.contains("name: pingora-config"));
|
assert!(result.contains("name: pingora-config"));
|
||||||
assert!(result.contains("name: pingora"));
|
assert!(result.contains("name: pingora"));
|
||||||
assert!(!result.contains("namespace: lasuite"));
|
assert!(!result.contains("namespace: stalwart"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -857,13 +857,13 @@ spec:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_input_returns_empty() {
|
fn test_empty_input_returns_empty() {
|
||||||
let result = filter_by_namespace("", "lasuite");
|
let result = filter_by_namespace("", "stalwart");
|
||||||
assert!(result.trim().is_empty());
|
assert!(result.trim().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_result_starts_with_separator() {
|
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("---"));
|
assert!(result.starts_with("---"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,7 +883,7 @@ spec:
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_single_doc_not_matching() {
|
fn test_single_doc_not_matching() {
|
||||||
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
|
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());
|
assert!(result.trim().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const PG_USERS: &[&str] = &[
|
|||||||
"stalwart",
|
"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 ──────────────────────────────────────────────────────────
|
// ── Key generation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -442,18 +442,6 @@ pub async fn cmd_seed() -> Result<()> {
|
|||||||
.get("kratos-secrets-cookie")
|
.get("kratos-secrets-cookie")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.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
|
let gitea_admin_pass = creds
|
||||||
.get("gitea-admin-password")
|
.get("gitea-admin-password")
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -670,31 +658,7 @@ pub async fn cmd_seed() -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
k::ensure_ns("lasuite").await?;
|
let _ = (s3_access_key, s3_secret_key);
|
||||||
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?;
|
|
||||||
|
|
||||||
k::ensure_ns("matrix").await?;
|
k::ensure_ns("matrix").await?;
|
||||||
k::ensure_ns("media").await?;
|
k::ensure_ns("media").await?;
|
||||||
@@ -1091,7 +1055,7 @@ mod tests {
|
|||||||
fn test_smtp_uri() {
|
fn test_smtp_uri() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SMTP_URI,
|
SMTP_URI,
|
||||||
"smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true"
|
"smtp://stalwart.stalwart.svc.cluster.local:25/?skip_ssl_verify=true"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -560,24 +560,6 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed resource server allowed audiences for La Suite external APIs.
|
|
||||||
// Combines the static sunbeam-cli client ID with dynamic service client IDs.
|
|
||||||
ok("Configuring La Suite resource server audiences...");
|
|
||||||
{
|
|
||||||
let mut rs_audiences = HashMap::new();
|
|
||||||
// sunbeam-cli is always static (OAuth2Client CRD name)
|
|
||||||
let mut audiences = vec!["sunbeam-cli".to_string()];
|
|
||||||
// Read the messages client ID from the oidc-messages secret if available
|
|
||||||
if let Ok(client_id) = crate::kube::kube_get_secret_field("lasuite", "oidc-messages", "CLIENT_ID").await {
|
|
||||||
audiences.push(client_id);
|
|
||||||
}
|
|
||||||
rs_audiences.insert(
|
|
||||||
"OIDC_RS_ALLOWED_AUDIENCES".to_string(),
|
|
||||||
audiences.join(","),
|
|
||||||
);
|
|
||||||
bao.kv_put("secret", "drive-rs-audiences", &rs_audiences).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch gitea admin credentials into secret/sol for Sol's Gitea integration.
|
// Patch gitea admin credentials into secret/sol for Sol's Gitea integration.
|
||||||
// Uses kv_patch to preserve manually-set keys (matrix-access-token etc.).
|
// 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",
|
"auth/kubernetes/role/vso",
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"bound_service_account_names": "default",
|
"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",
|
"policies": "vso-reader",
|
||||||
"ttl": "1h"
|
"ttl": "1h"
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ pub const SERVICES_TO_RESTART: &[(&str, &str)] = &[
|
|||||||
("ory", "login-ui"),
|
("ory", "login-ui"),
|
||||||
("devtools", "gitea"),
|
("devtools", "gitea"),
|
||||||
("storage", "seaweedfs-filer"),
|
("storage", "seaweedfs-filer"),
|
||||||
("lasuite", "hive"),
|
|
||||||
("lasuite", "people-backend"),
|
|
||||||
("lasuite", "people-frontend"),
|
|
||||||
("lasuite", "people-celery-worker"),
|
|
||||||
("lasuite", "people-celery-beat"),
|
|
||||||
("lasuite", "projects"),
|
|
||||||
("matrix", "tuwunel"),
|
("matrix", "tuwunel"),
|
||||||
("media", "livekit-server"),
|
("media", "livekit-server"),
|
||||||
];
|
];
|
||||||
@@ -446,13 +440,15 @@ mod tests {
|
|||||||
assert!(MANAGED_NS.contains(&"data"));
|
assert!(MANAGED_NS.contains(&"data"));
|
||||||
assert!(MANAGED_NS.contains(&"devtools"));
|
assert!(MANAGED_NS.contains(&"devtools"));
|
||||||
assert!(MANAGED_NS.contains(&"ingress"));
|
assert!(MANAGED_NS.contains(&"ingress"));
|
||||||
assert!(MANAGED_NS.contains(&"lasuite"));
|
|
||||||
assert!(MANAGED_NS.contains(&"matrix"));
|
assert!(MANAGED_NS.contains(&"matrix"));
|
||||||
assert!(MANAGED_NS.contains(&"media"));
|
assert!(MANAGED_NS.contains(&"media"));
|
||||||
|
assert!(MANAGED_NS.contains(&"stalwart"));
|
||||||
assert!(MANAGED_NS.contains(&"storage"));
|
assert!(MANAGED_NS.contains(&"storage"));
|
||||||
assert!(MANAGED_NS.contains(&"monitoring"));
|
assert!(MANAGED_NS.contains(&"monitoring"));
|
||||||
assert!(MANAGED_NS.contains(&"vault-secrets-operator"));
|
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]
|
#[test]
|
||||||
@@ -462,10 +458,10 @@ mod tests {
|
|||||||
assert!(SERVICES_TO_RESTART.contains(&("ory", "login-ui")));
|
assert!(SERVICES_TO_RESTART.contains(&("ory", "login-ui")));
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("devtools", "gitea")));
|
assert!(SERVICES_TO_RESTART.contains(&("devtools", "gitea")));
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("storage", "seaweedfs-filer")));
|
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(&("matrix", "tuwunel")));
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("media", "livekit-server")));
|
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]
|
#[test]
|
||||||
@@ -516,7 +512,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_restart_filter_all() {
|
fn test_restart_filter_all() {
|
||||||
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART.to_vec();
|
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART.to_vec();
|
||||||
assert_eq!(matched.len(), 13);
|
assert_eq!(matched.len(), 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,216 +11,11 @@ use super::{
|
|||||||
next_employee_id, short_id, PortForward, SMTP_LOCAL_PORT,
|
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
|
// 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(
|
async fn send_welcome_email(
|
||||||
domain: &str,
|
domain: &str,
|
||||||
email: &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.
|
profile -- add your name, profile picture, and any other details.
|
||||||
|
|
||||||
Your services:
|
Your services:
|
||||||
Calendar: https://cal.{domain}
|
|
||||||
Drive: https://drive.{domain}
|
|
||||||
Mail: https://mail.{domain}
|
Mail: https://mail.{domain}
|
||||||
Meet: https://meet.{domain}
|
|
||||||
Projects: https://projects.{domain}
|
|
||||||
Source Code: https://src.{domain}
|
Source Code: https://src.{domain}
|
||||||
|
|
||||||
Messages (Matrix):
|
Messages (Matrix):
|
||||||
@@ -296,7 +87,7 @@ Messages (Matrix):
|
|||||||
.body(body_text)
|
.body(body_text)
|
||||||
.ctx("Failed to build email message")?;
|
.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")
|
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||||
.port(SMTP_LOCAL_PORT)
|
.port(SMTP_LOCAL_PORT)
|
||||||
@@ -331,7 +122,7 @@ pub async fn cmd_user_onboard(
|
|||||||
|
|
||||||
let pf = PortForward::kratos().await?;
|
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?;
|
let existing = find_identity(&pf.base_url, email, false).await?;
|
||||||
|
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
@@ -412,12 +203,6 @@ pub async fn cmd_user_onboard(
|
|||||||
|
|
||||||
drop(pf);
|
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 {
|
if send_email {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::kube::get_domain().await?;
|
||||||
let recipient = if notify.is_empty() { email } else { notify };
|
let recipient = if notify.is_empty() { email } else { notify };
|
||||||
@@ -498,19 +283,8 @@ pub async fn cmd_user_offboard(target: &str) -> Result<()> {
|
|||||||
|
|
||||||
drop(pf);
|
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)));
|
ok(&format!("Offboarding complete for {}...", short_id(&iid)));
|
||||||
warn("Existing access tokens expire within ~1h (Hydra TTL).");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user