Browse Source

feat(hybridcloud) Enable Client to do domain splitting (#56692)

As part of hybrid cloud we need to start splitting requests from the
frontend to the region and control silo domains. I'd like to hide this
logic from most of the application as most environments won't have
multi-region, and we need additional workarounds for the `yarn dev-ui`
workflow.

Because handling subdomains in `dev-ui` requires additional host file
entries, I chose to go with a path prefix `/region/$region` instead.
This path prefix is handled by webpack proxy and removed when the
request is forwarded to the appropriate region servers.
Mark Story 1 year ago
parent
commit
cdd3ab00b7
4 changed files with 187 additions and 5 deletions
  1. 1 0
      static/app/actionCreators/organizations.tsx
  2. 99 1
      static/app/api.spec.tsx
  3. 59 4
      static/app/api.tsx
  4. 28 0
      webpack.config.ts

+ 1 - 0
static/app/actionCreators/organizations.tsx

@@ -216,6 +216,7 @@ export async function fetchOrganizations(api: Client, query?: Record<string, any
   const results = await Promise.all(
     regions.map(region =>
       api.requestPromise(`/organizations/`, {
+        // TODO(hybridcloud) Revisit this once domain splitting is working
         host: region.url,
         query,
       })

+ 99 - 1
static/app/api.spec.tsx

@@ -1,6 +1,9 @@
-import {Request} from 'sentry/api';
+import {Request, resolveHostname} from 'sentry/api';
 import {PROJECT_MOVED} from 'sentry/constants/apiErrorCodes';
 
+import ConfigStore from './stores/configStore';
+import OrganizationStore from './stores/organizationStore';
+
 jest.unmock('sentry/api');
 
 describe('api', function () {
@@ -84,3 +87,98 @@ describe('api', function () {
     ).not.toThrow();
   });
 });
+
+describe('resolveHostname', function () {
+  let devUi, orgstate, configstate;
+
+  const controlPath = '/api/0/broadcasts/';
+  const regionPath = '/api/0/organizations/slug/issues/';
+
+  beforeEach(function () {
+    orgstate = OrganizationStore.get();
+    configstate = ConfigStore.getState();
+    devUi = window.__SENTRY_DEV_UI;
+
+    OrganizationStore.onUpdate(
+      TestStubs.Organization({features: ['frontend-domainsplit']})
+    );
+    ConfigStore.loadInitialData({
+      ...configstate,
+      links: {
+        organizationUrl: 'https://acme.sentry.io',
+        sentryUrl: 'https://sentry.io',
+        regionUrl: 'https://us.sentry.io',
+      },
+    });
+  });
+
+  afterEach(() => {
+    window.__SENTRY_DEV_UI = devUi;
+    OrganizationStore.onUpdate(orgstate.organization);
+    ConfigStore.loadInitialData(configstate);
+  });
+
+  it('does nothing without feature', function () {
+    // Org does not have the required feature.
+    OrganizationStore.onUpdate(TestStubs.Organization());
+
+    let result = resolveHostname(controlPath);
+    expect(result).toBe(controlPath);
+
+    // Explicit domains still work.
+    result = resolveHostname(controlPath, 'https://sentry.io');
+    expect(result).toBe(`https://sentry.io${controlPath}`);
+
+    result = resolveHostname(regionPath, 'https://de.sentry.io');
+    expect(result).toBe(`https://de.sentry.io${regionPath}`);
+  });
+
+  it('adds domains when feature enabled', function () {
+    let result = resolveHostname(regionPath);
+    expect(result).toBe('https://us.sentry.io/api/0/organizations/slug/issues/');
+
+    result = resolveHostname(controlPath);
+    expect(result).toBe('https://sentry.io/api/0/broadcasts/');
+  });
+
+  it('uses paths for region silo in dev-ui', function () {
+    window.__SENTRY_DEV_UI = true;
+
+    let result = resolveHostname(regionPath);
+    expect(result).toBe('/region/us/api/0/organizations/slug/issues/');
+
+    result = resolveHostname(controlPath);
+    expect(result).toBe('/api/0/broadcasts/');
+  });
+
+  it('removes sentryUrl from dev-ui mode requests', function () {
+    window.__SENTRY_DEV_UI = true;
+
+    let result = resolveHostname(regionPath, 'https://sentry.io');
+    expect(result).toBe('/api/0/organizations/slug/issues/');
+
+    result = resolveHostname(controlPath, 'https://sentry.io');
+    expect(result).toBe('/api/0/broadcasts/');
+  });
+
+  it('removes sentryUrl from dev-ui mode requests when feature is off', function () {
+    window.__SENTRY_DEV_UI = true;
+    // Org does not have the required feature.
+    OrganizationStore.onUpdate(TestStubs.Organization());
+
+    let result = resolveHostname(controlPath);
+    expect(result).toBe(controlPath);
+
+    // control silo shaped URLs don't get a host
+    result = resolveHostname(controlPath, 'https://sentry.io');
+    expect(result).toBe(controlPath);
+
+    result = resolveHostname(regionPath, 'https://de.sentry.io');
+    expect(result).toBe(`/region/de${regionPath}`);
+  });
+
+  it('preserves host parameters', function () {
+    const result = resolveHostname(regionPath, 'https://de.sentry.io');
+    expect(result).toBe('https://de.sentry.io/api/0/organizations/slug/issues/');
+  });
+});

+ 59 - 4
static/app/api.tsx

@@ -10,12 +10,16 @@ import {
   SUDO_REQUIRED,
   SUPERUSER_REQUIRED,
 } from 'sentry/constants/apiErrorCodes';
+import controlsilopatterns from 'sentry/data/controlsiloUrlPatterns';
 import {metric} from 'sentry/utils/analytics';
 import getCsrfToken from 'sentry/utils/getCsrfToken';
 import {uniqueId} from 'sentry/utils/guid';
 import RequestError from 'sentry/utils/requestError/requestError';
 import {sanitizePath} from 'sentry/utils/requestError/sanitizePath';
 
+import ConfigStore from './stores/configStore';
+import OrganizationStore from './stores/organizationStore';
+
 export class Request {
   /**
    * Is the request still in flight
@@ -162,15 +166,14 @@ function buildRequestUrl(baseUrl: string, path: string, options: RequestOptions)
   // Append the baseUrl if required
   let fullUrl = path.includes(baseUrl) ? path : baseUrl + path;
 
+  // Apply path and domain transforms for hybrid-cloud
+  fullUrl = resolveHostname(fullUrl, options.host);
+
   // Append query parameters
   if (params) {
     fullUrl += fullUrl.includes('?') ? `&${params}` : `?${params}`;
   }
 
-  if (options.host) {
-    fullUrl = `${options.host}${fullUrl}`;
-  }
-
   return fullUrl;
 }
 
@@ -635,3 +638,55 @@ export class Client {
     );
   }
 }
+
+export function resolveHostname(path: string, hostname?: string): string {
+  const storeState = OrganizationStore.get();
+  const configLinks = ConfigStore.get('links');
+
+  hostname = hostname ?? '';
+  if (!hostname && storeState.organization?.features.includes('frontend-domainsplit')) {
+    const isControlSilo = detectControlSiloPath(path);
+    if (!isControlSilo && configLinks.regionUrl) {
+      hostname = configLinks.regionUrl;
+    }
+    if (isControlSilo && configLinks.sentryUrl) {
+      hostname = configLinks.sentryUrl;
+    }
+  }
+
+  // If we're making a request to the applications' root
+  // domain, we can drop the domain as webpack devserver will add one.
+  // TODO(hybridcloud) This can likely be removed when sentry.types.region.Region.to_url()
+  // loses the monolith mode condition.
+  if (window.__SENTRY_DEV_UI && hostname === configLinks.sentryUrl) {
+    hostname = '';
+  }
+
+  // When running as yarn dev-ui we can't spread requests across domains because
+  // of CORS. Instead we extract the subdomain from the hostname
+  // and prepend the URL with `/region/$name` so that webpack-devserver proxy
+  // can route requests to the regions.
+  if (hostname && window.__SENTRY_DEV_UI) {
+    const domainpattern = /https?\:\/\/([^.]*)\.sentry\.io/;
+    const domainmatch = hostname.match(domainpattern);
+    if (domainmatch) {
+      hostname = '';
+      path = `/region/${domainmatch[1]}${path}`;
+    }
+  }
+  if (hostname) {
+    path = `${hostname}${path}`;
+  }
+
+  return path;
+}
+
+function detectControlSiloPath(path: string): boolean {
+  path = path.startsWith('/') ? path.substring(1) : path;
+  for (const pattern of controlsilopatterns) {
+    if (pattern.test(path)) {
+      return true;
+    }
+  }
+  return false;
+}

+ 28 - 0
webpack.config.ts

@@ -671,6 +671,34 @@ if (IS_UI_DEV_ONLY) {
           return orgSlug ? `https://${orgSlug}.sentry.io` : 'https://sentry.io';
         },
       },
+      {
+        // Handle dev-ui region silo requests.
+        // Normally regions act as subdomains, but doing so in dev-ui
+        // would result in requests bypassing webpack proxy and being sent
+        // directly to region servers. These requests would fail because of CORS.
+        // Instead Client prefixes region requests with `/region/$name` which
+        // we rewrite in the proxy.
+        context: ['/region/'],
+        target: 'https://us.sentry.io',
+        secure: false,
+        changeOrigin: true,
+        headers: {
+          Referer: 'https://sentry.io/',
+          'Document-Policy': 'js-profiling',
+        },
+        cookieDomainRewrite: {'.sentry.io': 'localhost'},
+        pathRewrite: {
+          '^/region/[^/]*': '',
+        },
+        router: req => {
+          const regionPathPattern = /^\/region\/([^\/]+)/;
+          const regionname = req.path.match(regionPathPattern);
+          if (regionname) {
+            return `https://${regionname[1]}.sentry.io`;
+          }
+          return 'https://sentry.io';
+        },
+      },
     ],
     historyApiFallback: {
       rewrites: [{from: /^\/.*$/, to: '/_assets/index.html'}],