Skip to content

Commit 5898da8

Browse files
fix(hybrid-cloud): Adds region fan-out to organizations list command (#1860)
1 parent 54cde67 commit 5898da8

4 files changed

Lines changed: 140 additions & 20 deletions

File tree

src/api.rs

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ pub enum ApiErrorKind {
252252
RequestFailed,
253253
#[error("could not compress data")]
254254
CompressionFailed,
255+
#[error("region overrides cannot be applied to absolute urls")]
256+
InvalidRegionRequest,
255257
}
256258

257259
#[derive(Debug, thiserror::Error)]
@@ -391,28 +393,65 @@ impl Api {
391393
/// URL is just a path then it's relative to the configured API host
392394
/// and authentication is automatically enabled.
393395
pub fn request(&self, method: Method, url: &str) -> ApiResult<ApiRequest> {
394-
let mut handle = self.pool.get().unwrap();
395-
handle.reset();
396-
if !self.config.allow_keepalive() {
397-
handle.forbid_reuse(true).ok();
398-
}
399-
let mut ssl_opts = curl::easy::SslOpt::new();
400-
if self.config.disable_ssl_revocation_check() {
401-
ssl_opts.no_revoke(true);
396+
let (url, auth) = self.resolve_base_url_and_auth(url, None)?;
397+
self.construct_api_request(method, &url, auth)
398+
}
399+
400+
/// Like `request`, but constructs a new `ApiRequest` using the base URL
401+
/// plus a region prefix for requests that must be routed to a region.
402+
pub fn region_request(
403+
&self,
404+
method: Method,
405+
url: &str,
406+
region: &Region,
407+
) -> ApiResult<ApiRequest> {
408+
let (url, auth) = self.resolve_base_url_and_auth(url, Some(region))?;
409+
self.construct_api_request(method, &url, auth)
410+
}
411+
412+
fn resolve_base_url_and_auth(
413+
&self,
414+
url: &str,
415+
region: Option<&Region>,
416+
) -> ApiResult<(String, Option<&Auth>)> {
417+
if is_absolute_url(url) && region.is_some() {
418+
return Err(ApiErrorKind::InvalidRegionRequest.into());
402419
}
403-
handle.ssl_options(&ssl_opts)?;
420+
404421
let (url, auth) = if is_absolute_url(url) {
405422
(Cow::Borrowed(url), None)
406423
} else {
424+
let host_override = region.map(|rg| rg.url.as_str());
425+
407426
(
408-
Cow::Owned(match self.config.get_api_endpoint(url) {
427+
Cow::Owned(match self.config.get_api_endpoint(url, host_override) {
409428
Ok(rv) => rv,
410429
Err(err) => return Err(ApiError::with_source(ApiErrorKind::BadApiUrl, err)),
411430
}),
412431
self.config.get_auth(),
413432
)
414433
};
415434

435+
Ok((url.into_owned(), auth))
436+
}
437+
438+
fn construct_api_request(
439+
&self,
440+
method: Method,
441+
url: &str,
442+
auth: Option<&Auth>,
443+
) -> ApiResult<ApiRequest> {
444+
let mut handle = self.pool.get().unwrap();
445+
handle.reset();
446+
if !self.config.allow_keepalive() {
447+
handle.forbid_reuse(true).ok();
448+
}
449+
let mut ssl_opts = curl::easy::SslOpt::new();
450+
if self.config.disable_ssl_revocation_check() {
451+
ssl_opts.no_revoke(true);
452+
}
453+
handle.ssl_options(&ssl_opts)?;
454+
416455
if let Some(proxy_url) = self.config.get_proxy_url() {
417456
handle.proxy(&proxy_url)?;
418457
}
@@ -431,7 +470,7 @@ impl Api {
431470
let env = self.config.get_pipeline_env();
432471
let headers = self.config.get_headers();
433472

434-
ApiRequest::create(handle, &method, &url, auth, env, headers)
473+
ApiRequest::create(handle, &method, url, auth, env, headers)
435474
}
436475

437476
/// Convenience method that performs a request using DSN as authentication method.
@@ -445,7 +484,7 @@ impl Api {
445484
// We resolve an absolute URL to skip default authentication flow.
446485
let url = self
447486
.config
448-
.get_api_endpoint(url)
487+
.get_api_endpoint(url, None)
449488
.map_err(|err| ApiError::with_source(ApiErrorKind::BadApiUrl, err))?;
450489

451490
let mut request = self
@@ -1443,11 +1482,19 @@ impl Api {
14431482
}
14441483

14451484
/// List all organizations associated with the authenticated token
1446-
pub fn list_organizations(&self) -> ApiResult<Vec<Organization>> {
1485+
/// in the given `Region`. If no `Region` is provided, we assume
1486+
/// we're issuing a request to a monolith deployment.
1487+
pub fn list_organizations(&self, region: Option<&Region>) -> ApiResult<Vec<Organization>> {
14471488
let mut rv = vec![];
14481489
let mut cursor = "".to_string();
14491490
loop {
1450-
let resp = self.get(&format!("/organizations/?cursor={}", QueryArg(&cursor)))?;
1491+
let current_path = &format!("/organizations/?cursor={}", QueryArg(&cursor));
1492+
let resp = if let Some(rg) = region {
1493+
self.region_request(Method::Get, current_path, rg)?.send()?
1494+
} else {
1495+
self.get(current_path)?
1496+
};
1497+
14511498
if resp.status() == 404 || (resp.status() == 400 && !cursor.is_empty()) {
14521499
if rv.is_empty() {
14531500
return Err(ApiErrorKind::ResourceNotFound.into());
@@ -1466,6 +1513,22 @@ impl Api {
14661513
Ok(rv)
14671514
}
14681515

1516+
pub fn list_available_regions(&self) -> ApiResult<Vec<Region>> {
1517+
let resp = self.get("/users/me/regions/")?;
1518+
if resp.status() == 404 {
1519+
// This endpoint may not exist for self-hosted users, so
1520+
// returning a default of [] seems appropriate.
1521+
return Ok(vec![]);
1522+
}
1523+
1524+
if resp.status() == 400 {
1525+
return Err(ApiErrorKind::ResourceNotFound.into());
1526+
}
1527+
1528+
let region_response = resp.convert::<RegionResponse>()?;
1529+
Ok(region_response.regions)
1530+
}
1531+
14691532
/// List all monitors associated with an organization
14701533
pub fn list_organization_monitors(&self, org: &str) -> ApiResult<Vec<Monitor>> {
14711534
let mut rv = vec![];
@@ -2947,3 +3010,14 @@ impl fmt::Display for ProcessedEventTag {
29473010
Ok(())
29483011
}
29493012
}
3013+
3014+
#[derive(Clone, Debug, Deserialize)]
3015+
pub struct Region {
3016+
pub name: String,
3017+
pub url: String,
3018+
}
3019+
3020+
#[derive(Clone, Debug, Deserialize)]
3021+
pub struct RegionResponse {
3022+
pub regions: Vec<Region>,
3023+
}

src/commands/organizations/list.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use anyhow::Result;
22
use clap::{ArgMatches, Command};
3+
use log::debug;
34

4-
use crate::api::Api;
5+
use crate::api::{Api, Organization};
56
use crate::utils::formatting::Table;
67

78
pub fn make_command(command: Command) -> Command {
@@ -10,7 +11,22 @@ pub fn make_command(command: Command) -> Command {
1011

1112
pub fn execute(_matches: &ArgMatches) -> Result<()> {
1213
let api = Api::current();
13-
let mut organizations = api.list_organizations()?;
14+
15+
// Query regions available to the current CLI user
16+
let regions = api.list_available_regions()?;
17+
18+
let mut organizations: Vec<Organization> = vec![];
19+
debug!("Available regions: {:?}", regions);
20+
21+
// Self-hosted instances won't have a region instance or prefix, so we
22+
// need to check before fanning out.
23+
if regions.len() > 1 {
24+
for region in regions {
25+
organizations.append(&mut api.list_organizations(Some(&region))?)
26+
}
27+
} else {
28+
organizations.append(&mut api.list_organizations(None)?)
29+
}
1430

1531
organizations.sort_by_key(|o| o.name.clone().to_lowercase());
1632

src/config.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,13 @@ impl Config {
270270
}
271271

272272
/// Returns the API URL for a path
273-
pub fn get_api_endpoint(&self, path: &str) -> Result<String> {
274-
let base = self.get_base_url()?;
273+
pub fn get_api_endpoint(&self, path: &str, base_url_override: Option<&str>) -> Result<String> {
274+
let base: &str = base_url_override
275+
.unwrap_or(self.get_base_url()?)
276+
.trim_end_matches('/');
275277
let path = path.trim_start_matches('/');
276278
let path = path.trim_start_matches("api/0/");
279+
277280
Ok(format!("{}/api/0/{}", base, path))
278281
}
279282

@@ -788,16 +791,26 @@ mod tests {
788791

789792
assert_eq!(
790793
config
791-
.get_api_endpoint("/organizations/test-org/chunk-upload/")
794+
.get_api_endpoint("/organizations/test-org/chunk-upload/", None)
792795
.unwrap(),
793796
"https://sentry.io/api/0/organizations/test-org/chunk-upload/"
794797
);
795798

796799
assert_eq!(
797800
config
798-
.get_api_endpoint("/api/0/organizations/test-org/chunk-upload/")
801+
.get_api_endpoint("/api/0/organizations/test-org/chunk-upload/", None)
799802
.unwrap(),
800803
"https://sentry.io/api/0/organizations/test-org/chunk-upload/"
801804
);
805+
806+
assert_eq!(
807+
config
808+
.get_api_endpoint(
809+
"/api/0/organizations/test-org/chunk-upload/",
810+
Some("https://us.sentry.io/")
811+
)
812+
.unwrap(),
813+
"https://us.sentry.io/api/0/organizations/test-org/chunk-upload/"
814+
);
802815
}
803816
}

tests/integration/organizations/list.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use mockito::server_url;
2+
13
use crate::integration::{mock_endpoint, register_test, EndpointOptions};
24

35
#[test]
@@ -6,6 +8,21 @@ fn command_organizations_list() {
68
EndpointOptions::new("GET", "/api/0/organizations/?cursor=", 200)
79
.with_response_file("organizations/get-organizations.json"),
810
);
11+
12+
let region_response = format!(
13+
r#"{{
14+
"regions": [{{
15+
"name": "monolith",
16+
"url": "{}"
17+
}}]
18+
}}"#,
19+
server_url(),
20+
);
21+
22+
let _mock_regions = mock_endpoint(
23+
EndpointOptions::new("GET", "/api/0/users/me/regions/", 200)
24+
.with_response_body(region_response),
25+
);
926
register_test("organizations/organizations-list.trycmd");
1027
}
1128

0 commit comments

Comments
 (0)