diff --git a/src/gitea.rs b/src/gitea.rs index b94924c5..2e107925 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -288,8 +288,8 @@ async fn configure_oidc(pod: &str, _password: &str) -> Result<()> { } // Create new OIDC auth source - let oidc_id = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await; - let oidc_secret = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await; + let oidc_id = kube_get_secret_field("devtools", "oidc-gitea", "CLIENT_ID").await; + let oidc_secret = kube_get_secret_field("devtools", "oidc-gitea", "CLIENT_SECRET").await; match (oidc_id, oidc_secret) { (Ok(oidc_id), Ok(oidc_sec)) => { diff --git a/src/kube.rs b/src/kube.rs index b0747fa9..8c5ad95a 100644 --- a/src/kube.rs +++ b/src/kube.rs @@ -403,7 +403,7 @@ pub async fn kube_rollout_restart(ns: &str, deployment: &str) -> Result<()> { /// Discover the active domain from cluster state. /// /// Tries the gitea-inline-config secret first (DOMAIN=src.), -/// falls back to lasuite-oidc-provider configmap, then Lima VM IP. +/// then falls back to the Lima VM IP for local development. #[allow(dead_code)] pub async fn get_domain() -> Result { // 1. Gitea inline-config secret @@ -420,25 +420,7 @@ pub async fn get_domain() -> Result { } } - // 2. Fallback: lasuite-oidc-provider configmap - { - let client = get_client().await?; - let api: Api = - Api::namespaced(client.clone(), "lasuite"); - if let Ok(Some(cm)) = api.get_opt("lasuite-oidc-provider").await { - if let Some(data) = &cm.data { - if let Some(endpoint) = data.get("OIDC_OP_JWKS_ENDPOINT") { - if let Some(rest) = endpoint.split("https://auth.").nth(1) { - if let Some(domain) = rest.split('/').next() { - return Ok(domain.to_string()); - } - } - } - } - } - } - - // 3. Local dev fallback: Lima VM IP + // 2. Local dev fallback: Lima VM IP let ip = get_lima_ip().await; Ok(format!("{ip}.sslip.io")) } diff --git a/src/users.rs b/src/users.rs index c452e83d..30ce7994 100644 --- a/src/users.rs +++ b/src/users.rs @@ -537,216 +537,11 @@ pub async fn cmd_user_set_password(target: &str, password: &str) -> Result<()> { Ok(()) } -// --------------------------------------------------------------------------- -// 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 { - let client = crate::kube::get_client().await?; - let pods: kube::Api = - kube::Api::namespaced(client.clone(), ns); - - let label = format!("app.kubernetes.io/name={deployment}"); - let lp = kube::api::ListParams::default().labels(&label); - let pod_list = pods - .list(&lp) - .await - .with_ctx(|| format!("Failed to list pods for deployment {deployment} in {ns}"))?; - - for pod in &pod_list.items { - if let Some(status) = &pod.status { - let phase = status.phase.as_deref().unwrap_or(""); - if phase == "Running" { - if let Some(name) = &pod.metadata.name { - return Ok(name.clone()); - } - } - } - } - - // Fallback: try with app= label - let label2 = format!("app={deployment}"); - let lp2 = kube::api::ListParams::default().labels(&label2); - let pod_list2 = match pods.list(&lp2).await { - Ok(list) => list, - Err(_) => { - return Err(SunbeamError::kube(format!( - "No running pod found for deployment {deployment} in {ns}" - ))); - } - }; - - for pod in &pod_list2.items { - if let Some(status) = &pod.status { - let phase = status.phase.as_deref().unwrap_or(""); - if phase == "Running" { - if let Some(name) = &pod.metadata.name { - return Ok(name.clone()); - } - } - } - } - - Err(SunbeamError::kube(format!( - "No running pod found for deployment {deployment} in {ns}" - ))) -} - -/// Create a mailbox in Messages via kubectl exec into the backend. -async fn create_mailbox(email: &str, name: &str) { - let parts: Vec<&str> = email.splitn(2, '@').collect(); - if parts.len() != 2 { - warn(&format!("Invalid email for mailbox creation: {email}")); - return; - } - let local_part = parts[0]; - let domain_part = parts[1]; - let display_name = if name.is_empty() { local_part } else { name }; - let _ = display_name; // used in Python for future features; kept for parity - - step(&format!("Creating mailbox: {email}")); - - let pod = match pod_for_deployment("lasuite", "messages-backend").await { - Ok(p) => p, - Err(e) => { - warn(&format!("Could not find messages-backend pod: {e}")); - return; - } - }; - - let script = format!( - "mb, created = Mailbox.objects.get_or_create(\n local_part=\"{}\",\n domain=MailDomain.objects.get(name=\"{}\"),\n)\nprint(\"created\" if created else \"exists\")\n", - local_part, domain_part, - ); - - let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script]; - match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await { - Ok((0, output)) if output.contains("created") => { - ok(&format!("Mailbox {email} created.")); - } - Ok((0, output)) if output.contains("exists") => { - ok(&format!("Mailbox {email} already exists.")); - } - Ok((_, output)) => { - warn(&format!( - "Could not create mailbox (Messages backend may not be running): {output}" - )); - } - Err(e) => { - warn(&format!("Could not create mailbox: {e}")); - } - } -} - -/// Delete a mailbox and associated Django user in Messages. -async fn delete_mailbox(email: &str) { - let parts: Vec<&str> = email.splitn(2, '@').collect(); - if parts.len() != 2 { - warn(&format!("Invalid email for mailbox deletion: {email}")); - return; - } - let local_part = parts[0]; - let domain_part = parts[1]; - - step(&format!("Cleaning up mailbox: {email}")); - - let pod = match pod_for_deployment("lasuite", "messages-backend").await { - Ok(p) => p, - Err(e) => { - warn(&format!("Could not find messages-backend pod: {e}")); - return; - } - }; - - let script = format!( - "from django.contrib.auth import get_user_model\nUser = get_user_model()\ndeleted = 0\nfor mb in Mailbox.objects.filter(local_part=\"{local_part}\", domain__name=\"{domain_part}\"):\n mb.delete()\n deleted += 1\ntry:\n u = User.objects.get(email=\"{email}\")\n u.delete()\n deleted += 1\nexcept User.DoesNotExist:\n pass\nprint(f\"deleted {{deleted}}\")\n", - ); - - let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script]; - match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await { - Ok((0, output)) if output.contains("deleted") => { - ok("Mailbox and user cleaned up."); - } - Ok((_, output)) => { - warn(&format!("Could not clean up mailbox: {output}")); - } - Err(e) => { - warn(&format!("Could not clean up mailbox: {e}")); - } - } -} - -/// Create a Projects (Planka) user and add them as manager of the Default project. -async fn setup_projects_user(email: &str, name: &str) { - step(&format!("Setting up Projects user: {email}")); - - let pod = match pod_for_deployment("lasuite", "projects").await { - Ok(p) => p, - Err(e) => { - warn(&format!("Could not find projects pod: {e}")); - return; - } - }; - - let js = format!( - "const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n let user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{\n const id = Date.now().toString();\n await knex('user_account').insert({{\n id, email: '{email}', name: '{name}', password: '',\n is_admin: true, is_sso: true, language: 'en-US',\n created_at: new Date(), updated_at: new Date()\n }});\n user = {{id}};\n console.log('user_created');\n }} else {{\n console.log('user_exists');\n }}\n const project = await knex('project').where({{name: 'Default'}}).first();\n if (project) {{\n const exists = await knex('project_manager').where({{project_id: project.id, user_id: user.id}}).first();\n if (!exists) {{\n await knex('project_manager').insert({{\n id: (Date.now()+1).toString(), project_id: project.id,\n user_id: user.id, created_at: new Date()\n }});\n console.log('manager_added');\n }} else {{\n console.log('manager_exists');\n }}\n }} else {{\n console.log('no_default_project');\n }}\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n", - ); - - let cmd: Vec<&str> = vec!["node", "-e", &js]; - match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await { - Ok((0, output)) - if output.contains("manager_added") || output.contains("manager_exists") => - { - ok("Projects user ready."); - } - Ok((0, output)) if output.contains("no_default_project") => { - warn("No Default project found in Projects -- skip."); - } - Ok((_, output)) => { - warn(&format!("Could not set up Projects user: {output}")); - } - Err(e) => { - warn(&format!("Could not set up Projects user: {e}")); - } - } -} - -/// Remove a user from Projects (Planka) -- delete memberships and soft-delete user. -async fn cleanup_projects_user(email: &str) { - step(&format!("Cleaning up Projects user: {email}")); - - let pod = match pod_for_deployment("lasuite", "projects").await { - Ok(p) => p, - Err(e) => { - warn(&format!("Could not find projects pod: {e}")); - return; - } - }; - - let js = format!( - "const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n const user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{ console.log('not_found'); return; }}\n await knex('board_membership').where({{user_id: user.id}}).del();\n await knex('project_manager').where({{user_id: user.id}}).del();\n await knex('user_account').where({{id: user.id}}).update({{deleted_at: new Date()}});\n console.log('cleaned');\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n", - ); - - let cmd: Vec<&str> = vec!["node", "-e", &js]; - match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await { - Ok((0, output)) if output.contains("cleaned") => { - ok("Projects user cleaned up."); - } - Ok((_, output)) => { - warn(&format!("Could not clean up Projects user: {output}")); - } - Err(e) => { - warn(&format!("Could not clean up Projects user: {e}")); - } - } -} - // --------------------------------------------------------------------------- // Onboard // --------------------------------------------------------------------------- -/// Send a welcome email via cluster Postfix (port-forward to svc/postfix in lasuite). +/// Send a welcome email via cluster Stalwart SMTP. async fn send_welcome_email( domain: &str, email: &str, @@ -788,11 +583,7 @@ After that, head to https://auth.{domain}/settings to set up your profile -- add your name, profile picture, and any other details. Your services: - Calendar: https://cal.{domain} - Drive: https://drive.{domain} Mail: https://mail.{domain} - Meet: https://meet.{domain} - Projects: https://projects.{domain} Source Code: https://src.{domain} Messages (Matrix): @@ -822,7 +613,7 @@ Messages (Matrix): .body(body_text) .ctx("Failed to build email message")?; - let _pf = PortForward::new("lasuite", "postfix", SMTP_LOCAL_PORT, 25).await?; + let _pf = PortForward::new("stalwart", "stalwart", SMTP_LOCAL_PORT, 25).await?; let mailer = SmtpTransport::builder_dangerous("localhost") .port(SMTP_LOCAL_PORT) @@ -857,7 +648,7 @@ pub async fn cmd_user_onboard( let pf = PortForward::kratos().await?; - let (iid, recovery_link, recovery_code, is_new) = { + let (iid, recovery_link, recovery_code, _is_new) = { let existing = find_identity(&pf.base_url, email, false).await?; if let Some(existing) = existing { @@ -938,12 +729,6 @@ pub async fn cmd_user_onboard( drop(pf); - // Provision app-level accounts for new users - if is_new { - create_mailbox(email, name).await; - setup_projects_user(email, name).await; - } - if send_email { let domain = crate::kube::get_domain().await?; let recipient = if notify.is_empty() { email } else { notify }; @@ -1024,17 +809,6 @@ pub async fn cmd_user_offboard(target: &str) -> Result<()> { drop(pf); - // Clean up Messages mailbox and Projects user - let email = identity - .get("traits") - .and_then(|t| t.get("email")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if !email.is_empty() { - delete_mailbox(email).await; - cleanup_projects_user(email).await; - } - ok(&format!("Offboarding complete for {}...", short_id(&iid))); warn("Existing access tokens expire within ~1h (Hydra TTL)."); warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE (~1h).");