collab: Route `get_users_by_ids` through Cloud (#56105)

Marshall Bowers created

This PR makes it so we route the `UserService::get_users_by_ids` call
through Cloud instead of hitting the database.

We've introduced a new `CloudUserService` that will fetch the users from
Cloud using the internal API. Note that we've only implemented the
`get_users_by_ids` method on this service, as the endpoints for the
other methods don't yet exist.

We have also introduced a `TransitionalUserService` for the purposes of
gradually transitioning these calls over to Cloud. Right now it uses the
`CloudUserService` for the `get_users_by_ids` implementation, but then
uses the `DatabaseUserService` for the other methods.

Closes CLO-740.

Release Notes:

- N/A

Change summary

crates/cloud_api_types/src/cloud_api_types.rs  |   1 
crates/cloud_api_types/src/internal_api.rs     |  22 ++
crates/collab/.env.toml                        |   1 
crates/collab/k8s/collab.template.yml          |   5 
crates/collab/src/lib.rs                       |  22 ++
crates/collab/src/services/user_service.rs     | 147 ++++++++++++++++++++
crates/collab/tests/integration/test_server.rs |   1 
7 files changed, 196 insertions(+), 3 deletions(-)

Detailed changes

crates/cloud_api_types/src/internal_api.rs 🔗

@@ -0,0 +1,22 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct User {
+    pub id: String,
+    pub legacy_user_id: i32,
+    pub github_login: String,
+    pub github_user_id: i32,
+    pub name: Option<String>,
+    pub admin: bool,
+    pub connected_once: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LookUpUsersByLegacyIdBody {
+    pub legacy_user_ids: Vec<i32>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LookUpUsersByLegacyIdResponse {
+    pub users: Vec<User>,
+}

crates/collab/.env.toml 🔗

@@ -3,6 +3,7 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
 DATABASE_MAX_CONNECTIONS = 5
 HTTP_PORT = 8080
 ZED_ENVIRONMENT = "development"
+ZED_CLOUD_INTERNAL_API_KEY = "internal-api-key-secret"
 LIVEKIT_SERVER = "http://localhost:7880"
 LIVEKIT_KEY = "devkey"
 LIVEKIT_SECRET = "secret"

crates/collab/k8s/collab.template.yml 🔗

@@ -92,6 +92,11 @@ spec:
                 secretKeyRef:
                   name: zed-client
                   key: checksum-seed
+            - name: ZED_CLOUD_INTERNAL_API_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: zed-cloud
+                  key: internal-api-key
             - name: LIVEKIT_SERVER
               valueFrom:
                 secretKeyRef:

crates/collab/src/lib.rs 🔗

@@ -20,7 +20,9 @@ use serde::Deserialize;
 use std::{path::PathBuf, sync::Arc};
 use util::ResultExt;
 
-use crate::services::{DatabaseUserService, UserService};
+use crate::services::{
+    CloudUserService, DatabaseUserService, TransitionalUserService, UserService,
+};
 
 pub const VERSION: &str = env!("CARGO_PKG_VERSION");
 pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
@@ -139,6 +141,7 @@ pub struct Config {
     pub kinesis_access_key: Option<String>,
     pub kinesis_secret_key: Option<String>,
     pub zed_environment: Arc<str>,
+    pub zed_cloud_internal_api_key: String,
     pub zed_client_checksum_seed: Option<String>,
 }
 
@@ -176,6 +179,7 @@ impl Config {
             rust_log: None,
             log_json: None,
             zed_environment: "test".into(),
+            zed_cloud_internal_api_key: "test-internal-api-key".into(),
             blob_store_url: None,
             blob_store_region: None,
             blob_store_access_key: None,
@@ -252,7 +256,7 @@ impl AppState {
         let db = Arc::new(db);
         let this = Self {
             db: db.clone(),
-            http_client: Some(http_client),
+            http_client: Some(http_client.clone()),
             livekit_client,
             blob_store_client: build_blob_store_client(&config).await.log_err(),
             executor,
@@ -261,7 +265,19 @@ impl AppState {
             } else {
                 None
             },
-            user_service: Arc::new(DatabaseUserService::new(db)),
+            user_service: {
+                let database_user_service = DatabaseUserService::new(db);
+                let cloud_user_service = CloudUserService::new(
+                    http_client,
+                    config.zed_cloud_url().to_string(),
+                    config.zed_cloud_internal_api_key.clone(),
+                );
+
+                Arc::new(TransitionalUserService::new(
+                    cloud_user_service,
+                    database_user_service,
+                ))
+            },
             config,
         };
         Ok(Arc::new(this))

crates/collab/src/services/user_service.rs 🔗

@@ -1,6 +1,10 @@
 use std::sync::Arc;
 
+use anyhow::{Context as _, anyhow};
 use async_trait::async_trait;
+use cloud_api_types::internal_api::{
+    self, LookUpUsersByLegacyIdBody, LookUpUsersByLegacyIdResponse,
+};
 use rpc::proto;
 
 use crate::Result;
@@ -36,6 +40,149 @@ pub trait UserService: Send + Sync + 'static {
     }
 }
 
+/// A [`UserService`] implementation for transitioning from reading from the database to reading from Cloud.
+pub struct TransitionalUserService {
+    cloud_user_service: CloudUserService,
+    database_user_service: DatabaseUserService,
+}
+
+impl TransitionalUserService {
+    pub fn new(
+        cloud_user_service: CloudUserService,
+        database_user_service: DatabaseUserService,
+    ) -> Self {
+        Self {
+            cloud_user_service,
+            database_user_service,
+        }
+    }
+}
+
+#[async_trait]
+impl UserService for TransitionalUserService {
+    async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
+        self.cloud_user_service.get_users_by_ids(ids).await
+    }
+
+    async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
+        self.database_user_service
+            .get_user_by_github_login(github_login)
+            .await
+    }
+
+    async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
+        self.database_user_service
+            .fuzzy_search_users(query, limit)
+            .await
+    }
+
+    async fn search_channel_members(
+        &self,
+        channel: &Channel,
+        query: &str,
+        limit: u32,
+    ) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
+        self.database_user_service
+            .search_channel_members(channel, query, limit)
+            .await
+    }
+}
+
+/// A [`UserService`] implementation backed by Cloud.
+pub struct CloudUserService {
+    http_client: reqwest::Client,
+    zed_cloud_url: String,
+    internal_api_key: String,
+}
+
+impl CloudUserService {
+    pub fn new(
+        http_client: reqwest::Client,
+        zed_cloud_url: String,
+        internal_api_key: String,
+    ) -> Self {
+        Self {
+            http_client,
+            zed_cloud_url,
+            internal_api_key,
+        }
+    }
+}
+
+#[async_trait]
+impl UserService for CloudUserService {
+    async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
+        let response = self
+            .http_client
+            .post(format!(
+                "{}/internal/users/look_up_by_legacy_id",
+                &self.zed_cloud_url
+            ))
+            .header("Content-Type", "application/json")
+            .header(
+                "Authorization",
+                format!("Bearer {}", &self.internal_api_key),
+            )
+            .json(&LookUpUsersByLegacyIdBody {
+                legacy_user_ids: ids.into_iter().map(|id| id.0).collect(),
+            })
+            .send()
+            .await
+            .context("failed to get users by legacy IDs")?;
+
+        match response.error_for_status() {
+            Ok(response) => {
+                let response_body: LookUpUsersByLegacyIdResponse = response
+                    .json()
+                    .await
+                    .context("failed to parse response body")?;
+
+                Ok(response_body.users.into_iter().map(User::from).collect())
+            }
+            Err(_err) => Err(anyhow!("failed to get users by legacy IDs"))?,
+        }
+    }
+
+    async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
+        let _ = github_login;
+
+        unimplemented!("not yet implemented in Cloud")
+    }
+
+    async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
+        let _ = query;
+        let _ = limit;
+
+        unimplemented!("not yet implemented in Cloud")
+    }
+
+    async fn search_channel_members(
+        &self,
+        channel: &Channel,
+        query: &str,
+        limit: u32,
+    ) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
+        let _ = channel;
+        let _ = query;
+        let _ = limit;
+
+        unimplemented!("not yet implemented in Cloud")
+    }
+}
+
+impl From<internal_api::User> for User {
+    fn from(user: internal_api::User) -> Self {
+        Self {
+            id: UserId(user.legacy_user_id),
+            github_login: user.github_login,
+            github_user_id: user.github_user_id,
+            name: user.name,
+            admin: user.admin,
+            connected_once: user.connected_once,
+        }
+    }
+}
+
 /// A [`UserService`] implementation backed by the database.
 pub struct DatabaseUserService {
     database: Arc<Database>,

crates/collab/tests/integration/test_server.rs 🔗

@@ -592,6 +592,7 @@ impl TestServer {
                 rust_log: None,
                 log_json: None,
                 zed_environment: "test".into(),
+                zed_cloud_internal_api_key: "test-internal-api-key".into(),
                 blob_store_url: None,
                 blob_store_region: None,
                 blob_store_access_key: None,