LCOV - code coverage report
Current view: top level - proxy/src/console - messages.rs (source / functions) Coverage Total Hit
Test: ccf45ed1c149555259baec52d6229a81013dcd6a.info Lines: 61.7 % 183 113
Test Date: 2024-08-21 17:32:46 Functions: 23.2 % 164 38

            Line data    Source code
       1              : use measured::FixedCardinalityLabel;
       2              : use serde::{Deserialize, Serialize};
       3              : use std::fmt::{self, Display};
       4              : 
       5              : use crate::auth::IpPattern;
       6              : 
       7              : use crate::intern::{BranchIdInt, EndpointIdInt, ProjectIdInt};
       8              : use crate::proxy::retry::CouldRetry;
       9              : 
      10              : /// Generic error response with human-readable description.
      11              : /// Note that we can't always present it to user as is.
      12            0 : #[derive(Debug, Deserialize, Clone)]
      13              : pub struct ConsoleError {
      14              :     pub error: Box<str>,
      15              :     #[serde(skip)]
      16              :     pub http_status_code: http::StatusCode,
      17              :     pub status: Option<Status>,
      18              : }
      19              : 
      20              : impl ConsoleError {
      21            6 :     pub fn get_reason(&self) -> Reason {
      22            6 :         self.status
      23            6 :             .as_ref()
      24            6 :             .and_then(|s| s.details.error_info.as_ref())
      25            6 :             .map_or(Reason::Unknown, |e| e.reason)
      26            6 :     }
      27              : 
      28            0 :     pub fn get_user_facing_message(&self) -> String {
      29            0 :         use super::provider::errors::REQUEST_FAILED;
      30            0 :         self.status
      31            0 :             .as_ref()
      32            0 :             .and_then(|s| s.details.user_facing_message.as_ref())
      33            0 :             .map_or_else(|| {
      34            0 :                 // Ask @neondatabase/control-plane for review before adding more.
      35            0 :                 match self.http_status_code {
      36              :                     http::StatusCode::NOT_FOUND => {
      37              :                         // Status 404: failed to get a project-related resource.
      38            0 :                         format!("{REQUEST_FAILED}: endpoint cannot be found")
      39              :                     }
      40              :                     http::StatusCode::NOT_ACCEPTABLE => {
      41              :                         // Status 406: endpoint is disabled (we don't allow connections).
      42            0 :                         format!("{REQUEST_FAILED}: endpoint is disabled")
      43              :                     }
      44              :                     http::StatusCode::LOCKED | http::StatusCode::UNPROCESSABLE_ENTITY => {
      45              :                         // Status 423: project might be in maintenance mode (or bad state), or quotas exceeded.
      46            0 :                         format!("{REQUEST_FAILED}: endpoint is temporarily unavailable. Check your quotas and/or contact our support.")
      47              :                     }
      48            0 :                     _ => REQUEST_FAILED.to_owned(),
      49              :                 }
      50            0 :             }, |m| m.message.clone().into())
      51            0 :     }
      52              : }
      53              : 
      54              : impl Display for ConsoleError {
      55            0 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
      56            0 :         let msg: &str = self
      57            0 :             .status
      58            0 :             .as_ref()
      59            0 :             .and_then(|s| s.details.user_facing_message.as_ref())
      60            0 :             .map_or_else(|| self.error.as_ref(), |m| m.message.as_ref());
      61            0 :         write!(f, "{msg}")
      62            0 :     }
      63              : }
      64              : 
      65              : impl CouldRetry for ConsoleError {
      66           12 :     fn could_retry(&self) -> bool {
      67              :         // If the error message does not have a status,
      68              :         // the error is unknown and probably should not retry automatically
      69           12 :         let Some(status) = &self.status else {
      70            4 :             return false;
      71              :         };
      72              : 
      73              :         // retry if the retry info is set.
      74            8 :         if status.details.retry_info.is_some() {
      75            8 :             return true;
      76            0 :         }
      77            0 : 
      78            0 :         // if no retry info set, attempt to use the error code to guess the retry state.
      79            0 :         let reason = status
      80            0 :             .details
      81            0 :             .error_info
      82            0 :             .map_or(Reason::Unknown, |e| e.reason);
      83            0 : 
      84            0 :         reason.can_retry()
      85           12 :     }
      86              : }
      87              : 
      88            0 : #[derive(Debug, Deserialize, Clone)]
      89              : pub struct Status {
      90              :     pub code: Box<str>,
      91              :     pub message: Box<str>,
      92              :     pub details: Details,
      93              : }
      94              : 
      95            0 : #[derive(Debug, Deserialize, Clone)]
      96              : pub struct Details {
      97              :     pub error_info: Option<ErrorInfo>,
      98              :     pub retry_info: Option<RetryInfo>,
      99              :     pub user_facing_message: Option<UserFacingMessage>,
     100              : }
     101              : 
     102            0 : #[derive(Copy, Clone, Debug, Deserialize)]
     103              : pub struct ErrorInfo {
     104              :     pub reason: Reason,
     105              :     // Schema could also have `metadata` field, but it's not structured. Skip it for now.
     106              : }
     107              : 
     108            0 : #[derive(Clone, Copy, Debug, Deserialize, Default)]
     109              : pub enum Reason {
     110              :     /// RoleProtected indicates that the role is protected and the attempted operation is not permitted on protected roles.
     111              :     #[serde(rename = "ROLE_PROTECTED")]
     112              :     RoleProtected,
     113              :     /// ResourceNotFound indicates that a resource (project, endpoint, branch, etc.) wasn't found,
     114              :     /// usually due to the provided ID not being correct or because the subject doesn't have enough permissions to
     115              :     /// access the requested resource.
     116              :     /// Prefer a more specific reason if possible, e.g., ProjectNotFound, EndpointNotFound, etc.
     117              :     #[serde(rename = "RESOURCE_NOT_FOUND")]
     118              :     ResourceNotFound,
     119              :     /// ProjectNotFound indicates that the project wasn't found, usually due to the provided ID not being correct,
     120              :     /// or that the subject doesn't have enough permissions to access the requested project.
     121              :     #[serde(rename = "PROJECT_NOT_FOUND")]
     122              :     ProjectNotFound,
     123              :     /// EndpointNotFound indicates that the endpoint wasn't found, usually due to the provided ID not being correct,
     124              :     /// or that the subject doesn't have enough permissions to access the requested endpoint.
     125              :     #[serde(rename = "ENDPOINT_NOT_FOUND")]
     126              :     EndpointNotFound,
     127              :     /// BranchNotFound indicates that the branch wasn't found, usually due to the provided ID not being correct,
     128              :     /// or that the subject doesn't have enough permissions to access the requested branch.
     129              :     #[serde(rename = "BRANCH_NOT_FOUND")]
     130              :     BranchNotFound,
     131              :     /// RateLimitExceeded indicates that the rate limit for the operation has been exceeded.
     132              :     #[serde(rename = "RATE_LIMIT_EXCEEDED")]
     133              :     RateLimitExceeded,
     134              :     /// NonDefaultBranchComputeTimeExceeded indicates that the compute time quota of non-default branches has been
     135              :     /// exceeded.
     136              :     #[serde(rename = "NON_PRIMARY_BRANCH_COMPUTE_TIME_EXCEEDED")]
     137              :     NonDefaultBranchComputeTimeExceeded,
     138              :     /// ActiveTimeQuotaExceeded indicates that the active time quota was exceeded.
     139              :     #[serde(rename = "ACTIVE_TIME_QUOTA_EXCEEDED")]
     140              :     ActiveTimeQuotaExceeded,
     141              :     /// ComputeTimeQuotaExceeded indicates that the compute time quota was exceeded.
     142              :     #[serde(rename = "COMPUTE_TIME_QUOTA_EXCEEDED")]
     143              :     ComputeTimeQuotaExceeded,
     144              :     /// WrittenDataQuotaExceeded indicates that the written data quota was exceeded.
     145              :     #[serde(rename = "WRITTEN_DATA_QUOTA_EXCEEDED")]
     146              :     WrittenDataQuotaExceeded,
     147              :     /// DataTransferQuotaExceeded indicates that the data transfer quota was exceeded.
     148              :     #[serde(rename = "DATA_TRANSFER_QUOTA_EXCEEDED")]
     149              :     DataTransferQuotaExceeded,
     150              :     /// LogicalSizeQuotaExceeded indicates that the logical size quota was exceeded.
     151              :     #[serde(rename = "LOGICAL_SIZE_QUOTA_EXCEEDED")]
     152              :     LogicalSizeQuotaExceeded,
     153              :     /// RunningOperations indicates that the project already has some running operations
     154              :     /// and scheduling of new ones is prohibited.
     155              :     #[serde(rename = "RUNNING_OPERATIONS")]
     156              :     RunningOperations,
     157              :     /// ConcurrencyLimitReached indicates that the concurrency limit for an action was reached.
     158              :     #[serde(rename = "CONCURRENCY_LIMIT_REACHED")]
     159              :     ConcurrencyLimitReached,
     160              :     /// LockAlreadyTaken indicates that the we attempted to take a lock that was already taken.
     161              :     #[serde(rename = "LOCK_ALREADY_TAKEN")]
     162              :     LockAlreadyTaken,
     163              :     #[default]
     164              :     #[serde(other)]
     165              :     Unknown,
     166              : }
     167              : 
     168              : impl Reason {
     169            0 :     pub fn is_not_found(&self) -> bool {
     170            0 :         matches!(
     171            0 :             self,
     172              :             Reason::ResourceNotFound
     173              :                 | Reason::ProjectNotFound
     174              :                 | Reason::EndpointNotFound
     175              :                 | Reason::BranchNotFound
     176              :         )
     177            0 :     }
     178              : 
     179            0 :     pub fn can_retry(&self) -> bool {
     180            0 :         match self {
     181              :             // do not retry role protected errors
     182              :             // not a transitive error
     183            0 :             Reason::RoleProtected => false,
     184              :             // on retry, it will still not be found
     185              :             Reason::ResourceNotFound
     186              :             | Reason::ProjectNotFound
     187              :             | Reason::EndpointNotFound
     188            0 :             | Reason::BranchNotFound => false,
     189              :             // we were asked to go away
     190              :             Reason::RateLimitExceeded
     191              :             | Reason::NonDefaultBranchComputeTimeExceeded
     192              :             | Reason::ActiveTimeQuotaExceeded
     193              :             | Reason::ComputeTimeQuotaExceeded
     194              :             | Reason::WrittenDataQuotaExceeded
     195              :             | Reason::DataTransferQuotaExceeded
     196            0 :             | Reason::LogicalSizeQuotaExceeded => false,
     197              :             // transitive error. control plane is currently busy
     198              :             // but might be ready soon
     199              :             Reason::RunningOperations
     200              :             | Reason::ConcurrencyLimitReached
     201            0 :             | Reason::LockAlreadyTaken => true,
     202              :             // unknown error. better not retry it.
     203            0 :             Reason::Unknown => false,
     204              :         }
     205            0 :     }
     206              : }
     207              : 
     208            0 : #[derive(Copy, Clone, Debug, Deserialize)]
     209              : pub struct RetryInfo {
     210              :     pub retry_delay_ms: u64,
     211              : }
     212              : 
     213            0 : #[derive(Debug, Deserialize, Clone)]
     214              : pub struct UserFacingMessage {
     215              :     pub message: Box<str>,
     216              : }
     217              : 
     218              : /// Response which holds client's auth secret, e.g. [`crate::scram::ServerSecret`].
     219              : /// Returned by the `/proxy_get_role_secret` API method.
     220           18 : #[derive(Deserialize)]
     221              : pub struct GetRoleSecret {
     222              :     pub role_secret: Box<str>,
     223              :     pub allowed_ips: Option<Vec<IpPattern>>,
     224              :     pub project_id: Option<ProjectIdInt>,
     225              : }
     226              : 
     227              : // Manually implement debug to omit sensitive info.
     228              : impl fmt::Debug for GetRoleSecret {
     229            0 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     230            0 :         f.debug_struct("GetRoleSecret").finish_non_exhaustive()
     231            0 :     }
     232              : }
     233              : 
     234              : /// Response which holds compute node's `host:port` pair.
     235              : /// Returned by the `/proxy_wake_compute` API method.
     236            6 : #[derive(Debug, Deserialize)]
     237              : pub struct WakeCompute {
     238              :     pub address: Box<str>,
     239              :     pub aux: MetricsAuxInfo,
     240              : }
     241              : 
     242              : /// Async response which concludes the link auth flow.
     243              : /// Also known as `kickResponse` in the console.
     244            8 : #[derive(Debug, Deserialize)]
     245              : pub struct KickSession<'a> {
     246              :     /// Session ID is assigned by the proxy.
     247              :     pub session_id: &'a str,
     248              : 
     249              :     /// Compute node connection params.
     250              :     #[serde(deserialize_with = "KickSession::parse_db_info")]
     251              :     pub result: DatabaseInfo,
     252              : }
     253              : 
     254              : impl KickSession<'_> {
     255            2 :     fn parse_db_info<'de, D>(des: D) -> Result<DatabaseInfo, D::Error>
     256            2 :     where
     257            2 :         D: serde::Deserializer<'de>,
     258            2 :     {
     259            4 :         #[derive(Deserialize)]
     260            2 :         enum Wrapper {
     261            2 :             // Currently, console only reports `Success`.
     262            2 :             // `Failure(String)` used to be here... RIP.
     263            2 :             Success(DatabaseInfo),
     264            2 :         }
     265            2 : 
     266            2 :         Wrapper::deserialize(des).map(|x| match x {
     267            2 :             Wrapper::Success(info) => info,
     268            2 :         })
     269            2 :     }
     270              : }
     271              : 
     272              : /// Compute node connection params.
     273           56 : #[derive(Deserialize)]
     274              : pub struct DatabaseInfo {
     275              :     pub host: Box<str>,
     276              :     pub port: u16,
     277              :     pub dbname: Box<str>,
     278              :     pub user: Box<str>,
     279              :     /// Console always provides a password, but it might
     280              :     /// be inconvenient for debug with local PG instance.
     281              :     pub password: Option<Box<str>>,
     282              :     pub aux: MetricsAuxInfo,
     283              : }
     284              : 
     285              : // Manually implement debug to omit sensitive info.
     286              : impl fmt::Debug for DatabaseInfo {
     287            0 :     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     288            0 :         f.debug_struct("DatabaseInfo")
     289            0 :             .field("host", &self.host)
     290            0 :             .field("port", &self.port)
     291            0 :             .field("dbname", &self.dbname)
     292            0 :             .field("user", &self.user)
     293            0 :             .finish_non_exhaustive()
     294            0 :     }
     295              : }
     296              : 
     297              : /// Various labels for prometheus metrics.
     298              : /// Also known as `ProxyMetricsAuxInfo` in the console.
     299           50 : #[derive(Debug, Deserialize, Clone)]
     300              : pub struct MetricsAuxInfo {
     301              :     pub endpoint_id: EndpointIdInt,
     302              :     pub project_id: ProjectIdInt,
     303              :     pub branch_id: BranchIdInt,
     304              :     #[serde(default)]
     305              :     pub cold_start_info: ColdStartInfo,
     306              : }
     307              : 
     308           20 : #[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, FixedCardinalityLabel)]
     309              : #[serde(rename_all = "snake_case")]
     310              : pub enum ColdStartInfo {
     311              :     #[default]
     312              :     Unknown,
     313              :     /// Compute was already running
     314              :     Warm,
     315              :     #[serde(rename = "pool_hit")]
     316              :     #[label(rename = "pool_hit")]
     317              :     /// Compute was not running but there was an available VM
     318              :     VmPoolHit,
     319              :     #[serde(rename = "pool_miss")]
     320              :     #[label(rename = "pool_miss")]
     321              :     /// Compute was not running and there were no VMs available
     322              :     VmPoolMiss,
     323              : 
     324              :     // not provided by control plane
     325              :     /// Connection available from HTTP pool
     326              :     HttpPoolHit,
     327              :     /// Cached connection info
     328              :     WarmCached,
     329              : }
     330              : 
     331              : impl ColdStartInfo {
     332            0 :     pub fn as_str(&self) -> &'static str {
     333            0 :         match self {
     334            0 :             ColdStartInfo::Unknown => "unknown",
     335            0 :             ColdStartInfo::Warm => "warm",
     336            0 :             ColdStartInfo::VmPoolHit => "pool_hit",
     337            0 :             ColdStartInfo::VmPoolMiss => "pool_miss",
     338            0 :             ColdStartInfo::HttpPoolHit => "http_pool_hit",
     339            0 :             ColdStartInfo::WarmCached => "warm_cached",
     340              :         }
     341            0 :     }
     342              : }
     343              : 
     344              : #[cfg(test)]
     345              : mod tests {
     346              :     use super::*;
     347              :     use serde_json::json;
     348              : 
     349           10 :     fn dummy_aux() -> serde_json::Value {
     350           10 :         json!({
     351           10 :             "endpoint_id": "endpoint",
     352           10 :             "project_id": "project",
     353           10 :             "branch_id": "branch",
     354           10 :             "cold_start_info": "unknown",
     355           10 :         })
     356           10 :     }
     357              : 
     358              :     #[test]
     359            2 :     fn parse_kick_session() -> anyhow::Result<()> {
     360            2 :         // This is what the console's kickResponse looks like.
     361            2 :         let json = json!({
     362            2 :             "session_id": "deadbeef",
     363            2 :             "result": {
     364            2 :                 "Success": {
     365            2 :                     "host": "localhost",
     366            2 :                     "port": 5432,
     367            2 :                     "dbname": "postgres",
     368            2 :                     "user": "john_doe",
     369            2 :                     "password": "password",
     370            2 :                     "aux": dummy_aux(),
     371            2 :                 }
     372            2 :             }
     373            2 :         });
     374            2 :         let _: KickSession<'_> = serde_json::from_str(&json.to_string())?;
     375              : 
     376            2 :         Ok(())
     377            2 :     }
     378              : 
     379              :     #[test]
     380            2 :     fn parse_db_info() -> anyhow::Result<()> {
     381              :         // with password
     382            2 :         let _: DatabaseInfo = serde_json::from_value(json!({
     383            2 :             "host": "localhost",
     384            2 :             "port": 5432,
     385            2 :             "dbname": "postgres",
     386            2 :             "user": "john_doe",
     387            2 :             "password": "password",
     388            2 :             "aux": dummy_aux(),
     389            2 :         }))?;
     390              : 
     391              :         // without password
     392            2 :         let _: DatabaseInfo = serde_json::from_value(json!({
     393            2 :             "host": "localhost",
     394            2 :             "port": 5432,
     395            2 :             "dbname": "postgres",
     396            2 :             "user": "john_doe",
     397            2 :             "aux": dummy_aux(),
     398            2 :         }))?;
     399              : 
     400              :         // new field (forward compatibility)
     401            2 :         let _: DatabaseInfo = serde_json::from_value(json!({
     402            2 :             "host": "localhost",
     403            2 :             "port": 5432,
     404            2 :             "dbname": "postgres",
     405            2 :             "user": "john_doe",
     406            2 :             "project": "hello_world",
     407            2 :             "N.E.W": "forward compatibility check",
     408            2 :             "aux": dummy_aux(),
     409            2 :         }))?;
     410              : 
     411            2 :         Ok(())
     412            2 :     }
     413              : 
     414              :     #[test]
     415            2 :     fn parse_wake_compute() -> anyhow::Result<()> {
     416            2 :         let json = json!({
     417            2 :             "address": "0.0.0.0",
     418            2 :             "aux": dummy_aux(),
     419            2 :         });
     420            2 :         let _: WakeCompute = serde_json::from_str(&json.to_string())?;
     421            2 :         Ok(())
     422            2 :     }
     423              : 
     424              :     #[test]
     425            2 :     fn parse_get_role_secret() -> anyhow::Result<()> {
     426            2 :         // Empty `allowed_ips` field.
     427            2 :         let json = json!({
     428            2 :             "role_secret": "secret",
     429            2 :         });
     430            2 :         let _: GetRoleSecret = serde_json::from_str(&json.to_string())?;
     431            2 :         let json = json!({
     432            2 :             "role_secret": "secret",
     433            2 :             "allowed_ips": ["8.8.8.8"],
     434            2 :         });
     435            2 :         let _: GetRoleSecret = serde_json::from_str(&json.to_string())?;
     436            2 :         let json = json!({
     437            2 :             "role_secret": "secret",
     438            2 :             "allowed_ips": ["8.8.8.8"],
     439            2 :             "project_id": "project",
     440            2 :         });
     441            2 :         let _: GetRoleSecret = serde_json::from_str(&json.to_string())?;
     442              : 
     443            2 :         Ok(())
     444            2 :     }
     445              : }
        

Generated by: LCOV version 2.1-beta