spark-test commented on code in PR #59089:
URL: https://github.com/apache/spark/pull/59089#discussion_r4395105449
##########
core/src/main/scala/org/apache/spark/ui/JettyUtils.scala:
##########
@@ -294,7 +297,14 @@ private[spark] object JettyUtils extends Logging {
}
val existingContext =
Option(clientRequest.getHeader("X-Forwarded-Context")).getOrElse("")
val contextPath = Option(clientRequest.getContextPath).getOrElse("")
- val proxyContext = existingContext + contextPath + prefix
+ val proxyPrefix = if
(normalizedReverseProxyUrl.startsWith("http://") ||
+ normalizedReverseProxyUrl.startsWith("https://")) {
+ val uri = new java.net.URI(normalizedReverseProxyUrl)
+ Option(uri.getPath).getOrElse("")
+ } else {
+ normalizedReverseProxyUrl
+ }
+ val proxyContext = existingContext + proxyPrefix + contextPath +
prefix
Review Comment:
**Non-blocking (P2):** This line appends the path of
`spark.ui.reverseProxyUrl` after the incoming `X-Forwarded-Context`, even when
that header already carries the same front-end prefix. Take a Knox-style
gateway that serves the Master under `/path/to/spark`, strips that prefix and
sends `X-Forwarded-Context: /path/to/spark`, with
`spark.ui.reverseProxyUrl=https://gw.example.com/path/to/spark` set as
documented. A request to `/proxy/app-1/jobs/` now forwards
`/path/to/spark/path/to/spark/proxy/app-1`; the merge base forwarded
`/path/to/spark/proxy/app-1`.
Proxied UIs that take their root from this header render every link and
static asset under the doubled path, which 404s at the gateway. This is the
consumer SPARK-58521's chaining was added for. For example, a cluster-mode
driver submitted without `spark.ui.reverseProxy=true` gets only the injected
`-Dspark.ui.reverseProxyUrl`, so it never sets `spark.ui.proxyBase`, and
`uiRoot` falls back to the header. UIs with `spark.ui.proxyBase` set are
unaffected, and gateways that send no header are what this change fixes.
Suggested direction: when an incoming `X-Forwarded-Context` is present,
treat it as the external prefix, and add the reverseProxyUrl path only when no
header is present. Deriving that path once in `createProxyHandler`, from the
raw (still percent-encoded) path, also avoids re-parsing the URL on every
request.
This extends existing discussions
[1](https://github.com/apache/spark/pull/59089#issuecomment-5970307648),
[2](https://github.com/apache/spark/pull/59089#pullrequestreview-5400364129).
**Verification:**
- **Regression:** Send a proxied request whose incoming
`X-Forwarded-Context` equals the reverseProxyUrl path, and assert that the
forwarded header contains that prefix exactly once.
- **Behavior:** Send proxied requests without an incoming header, for both
an absolute and a relative reverseProxyUrl, and assert that the forwarded
header starts with the URL path.
- **Compatibility:** With no reverseProxyUrl configured, assert that the
forwarded header keeps the earlier chaining of the incoming context followed by
`/proxy/<id>`.
##########
core/src/test/scala/org/apache/spark/SparkContextSuite.scala:
##########
@@ -1417,6 +1417,99 @@ class SparkContextSuite extends SparkFunSuite with
LocalSparkContext with Eventu
assert(msg.contains("Cannot use the keyword 'proxy' or 'history' in
reverse proxy URL"))
}
+ test("SPARK-58893: propagate UI_REVERSE_PROXY_URL to spark.ui.proxyBase in
SparkContext") {
+ val conf = new SparkConf().setAppName("testReverseProxyBase")
+ .setMaster("local")
+ .set(UI_REVERSE_PROXY, true)
+ .set(UI_REVERSE_PROXY_URL, "http://proxyhost:8080/myprefix")
+ sc = new SparkContext(conf)
+ assert(System.getProperty("spark.ui.proxyBase") ===
+ s"http://proxyhost:8080/myprefix/proxy/${sc.applicationId}")
+ }
+
+ test("SPARK-58893: fallback spark.ui.proxyBase when UI_REVERSE_PROXY_URL is
empty") {
+ val sysProxyBase = "http://proxyhost:8080/sysprefix"
+ System.setProperty("spark.ui.proxyBase", sysProxyBase)
+ try {
+ val conf = new SparkConf().setAppName("testReverseProxyBaseSys")
+ .setMaster("local")
+ .set(UI_REVERSE_PROXY, true)
+ sc = new SparkContext(conf)
+ assert(System.getProperty("spark.ui.proxyBase") ===
+ s"$sysProxyBase/proxy/${sc.applicationId}")
+ } finally {
+ resetSparkContext()
+ System.clearProperty("spark.ui.proxyBase")
+ }
+ }
+
+ test("SPARK-58893: avoid compounding derived proxy root across multiple
SparkContexts") {
+ val conf1 = new SparkConf().setAppName("testApp1")
+ .setMaster("local")
+ .set(UI_REVERSE_PROXY, true)
+ sc = new SparkContext(conf1)
+ val app1Id = sc.applicationId
+ assert(System.getProperty("spark.ui.proxyBase") === s"/proxy/$app1Id")
+ resetSparkContext()
Review Comment:
**Nit (P3):** Stopping the first context here clears `spark.ui.proxyBase`:
no prior value was saved, so the new `stop()` takes its clear branch. The
second context therefore never sees `/proxy/local-<id>`, and the test passes
even with `.filterNot(isAppSpecificProxyBase)` removed. It also passes at the
merge base. The only test that pre-sets an app-specific base uses
`/proxy/application_`, so removing the `/proxy/app-`, `/proxy/spark-` or
`/proxy/local-` alternatives leaves every `SparkContextSuite` test green. The
`local-` check was added in response to review feedback that also asked for a
test pre-setting a `/proxy/local-<id>` base, and that test is still missing.
The test's name also claims more than it checks.
This extends an [existing
discussion](https://github.com/apache/spark/pull/59089#discussion_r4172835143).
**Verification:**
- **Regression:** For each recognized id form, pre-set a stale app-specific
base and assert that the new context derives `/proxy/<new id>` and clears the
stale value on stop, so that removing that form's check fails the test.
##########
core/src/test/scala/org/apache/spark/ui/JettyUtilsSuite.scala:
##########
@@ -68,4 +69,37 @@ class JettyUtilsSuite extends SparkFunSuite {
assert(!JettyUtils.isValidCsrfToken(newRequest(csrfTokenParam = ""),
"abc"))
assert(!JettyUtils.isValidCsrfToken(newRequest(csrfTokenParam = "abc"),
""))
}
+
+ test("SPARK-58893: reverse proxy addProxyHeaders and
filterServerResponseHeader rewriting") {
+ val normalizedProxyUrl = "http://proxyhost:8080/myprefix"
+ // Create an instance of the ProxyServlet created in
JettyUtils.createProxyHandler
+ val handler = JettyUtils.createProxyHandler(
+ id => if (id == "app-1") Some("http://target:8080") else None,
+ normalizedProxyUrl)
+
+ // Test reverse-proxy URL normalization and location header filter
+ val clientReq = mock(classOf[HttpServletRequest])
+ when(clientReq.getScheme).thenReturn("http")
+ when(clientReq.getHeader("host")).thenReturn("master:8080")
+ when(clientReq.getHeader("X-Forwarded-Context")).thenReturn(null)
+ when(clientReq.getContextPath).thenReturn("")
+ when(clientReq.getPathInfo).thenReturn("/app-1/jobs")
+
+ val serverResp = mock(classOf[CResponse])
+ val cReq = mock(classOf[CRequest])
+ when(serverResp.getRequest()).thenReturn(cReq)
+ when(cReq.getURI()).thenReturn(new java.net.URI("http://target:8080/jobs"))
+
+ // Verify location header rewrite logic from JettyUtils
+ val originalLocation = "http://master:8080/proxy/app-1/jobs/redirect"
+ val scheme = clientReq.getScheme
+ val host = clientReq.getHeader("host")
+ val rootProxyPrefix = s"$scheme://$host/proxy/"
+ assert(originalLocation.startsWith(rootProxyPrefix))
+
+ val rest = originalLocation.substring(rootProxyPrefix.length)
+ val rewrittenLocation =
s"${normalizedProxyUrl.stripSuffix("/")}/proxy/$rest"
Review Comment:
**Non-blocking (P2):** `rewrittenLocation` is computed here from the test's
own `normalizedProxyUrl` constant, and the next line asserts it against a
literal. The `handler` and the `clientReq`, `serverResp` and `cReq` mocks are
never passed to `addProxyHeaders` or `filterServerResponseHeader`. So removing
or breaking the `X-Forwarded-Context` composition or the `Location` re-root in
`JettyUtils.createProxyHandler` leaves this test green, despite its name. The
earlier review thread on the proxy handler asked for a test that drives a
proxied request through it, and this test does not do that.
The same change makes the reverse-proxy `MasterWorkerUISuite` test request
`/proxy/<workerId>/json/` instead of `/json`. That skips the worker's
trailing-slash redirect, which was the only end-to-end route through the
re-root with a configured URL. The real-request harness in `UISuite` (the
SPARK-58521 test) uses the default empty URL. So no test exercises either new
branch.
The existing SPARK-58521 harness in `UISuite` already sends real requests
through `createProxyHandler` and can be extended with a configured
reverseProxyUrl. In `MasterWorkerUISuite`, the trailing-slash redirect's
`Location` can be asserted without following it.
This extends existing discussions
[1](https://github.com/apache/spark/pull/59089#issuecomment-5975472018),
[2](https://github.com/apache/spark/pull/59089#pullrequestreview-5400364129),
[3](https://github.com/apache/spark/pull/59089#pullrequestreview-5401989181).
**Verification:**
- **Regression:** Disable the forwarded-context prefix and the `Location`
re-root one at a time, and confirm that a test fails for each.
- **Behavior:** Through real proxied requests, cover an absolute and a
relative reverseProxyUrl, each with and without an incoming
`X-Forwarded-Context`.
##########
core/src/main/scala/org/apache/spark/ui/JettyUtils.scala:
##########
@@ -315,6 +325,15 @@ private[spark] object JettyUtils extends Logging {
val newHeader = createProxyLocationHeader(headerValue, clientRequest,
serverResponse.getRequest().getURI())
if (newHeader != null) {
+ if (normalizedReverseProxyUrl.nonEmpty) {
+ val scheme = clientRequest.getScheme
+ val host = Option(clientRequest.getHeader("host")).getOrElse("")
+ val rootProxyPrefix = s"$scheme://$host/proxy/"
+ if (newHeader.startsWith(rootProxyPrefix)) {
+ val rest = newHeader.substring(rootProxyPrefix.length)
+ return s"$normalizedReverseProxyUrl/proxy/$rest"
Review Comment:
**Non-blocking (P2):** This line re-roots every proxied `Location` onto
`spark.ui.reverseProxyUrl`. Two documented layers already apply the front-end
prefix to these redirects, and each now applies it a second time:
- With `spark.ui.proxyRedirectUri` set, `ProxyRedirectHandler` wraps the
whole Master handler collection, including `/proxy`, and rebuilds every
`Location` as `proxyRedirectUri + uiRoot + path`. On the Master, `uiRoot` is
the reverseProxyUrl, because `Master.onStart` sets `spark.ui.proxyBase` to it.
Take `spark.ui.reverseProxyUrl=/path/to/spark` (a documented relative form)
with `spark.ui.proxyRedirectUri=https://mydomain.com`. A driver's redirect to
`/jobs/` now becomes
`https://mydomain.com/path/to/spark/path/to/spark/proxy/app-1/jobs/`; at the
merge base it was `https://mydomain.com/path/to/spark/proxy/app-1/jobs/`.
- The `spark.ui.reverseProxyUrl` docs make the front-end proxy responsible
for rewriting redirects that point at the Spark master. A front end that
preserves `Host` and rewrites `http://<public-host>/` to
`http://<public-host>/path/to/spark/` now also matches the re-rooted
`http://<public-host>/path/to/spark/proxy/...` and prefixes it again.
Every redirect a proxied UI issues is affected, including the
root-to-`/jobs/` redirect, Jetty's trailing-slash redirects, and kill redirects
inside the application UI. Redirects the Master issues itself (kill, hold,
resume) still go to `/` and still depend on those layers, so operators cannot
turn the layers off to compensate.
**Recommended change:** Re-root proxied redirects in the Master proxy only
when Spark has no other redirect rewriter for the Master UI. When
`spark.ui.proxyRedirectUri` is set, leave proxied `Location` headers in their
previous host-rooted form so that `ProxyRedirectHandler` applies the prefix
once. Update the `spark.ui.reverseProxyUrl` documentation to say that the
Master points redirects from proxied worker and application UIs at the
reverseProxyUrl, so front-end redirect rewriting should only handle redirects
that point at the Master itself.
**Why this works:** Each redirect gets exactly one prefixing layer:
`ProxyRedirectHandler` when `spark.ui.proxyRedirectUri` is configured;
otherwise the Master proxy servlet for proxied UIs, with the front end
documented as responsible only for Master-originated redirects.
**Scope:** Make the proxied-redirect re-root conditional on
`spark.ui.proxyRedirectUri` being absent, pass that setting from the Master UI
to the proxy handler, and document the resulting redirect responsibilities for
`spark.ui.reverseProxyUrl`.
**Compatibility:** Master-originated kill, hold and resume redirects, and
all proxied redirects when no reverseProxyUrl is configured, behave as they did
before this PR.
**Risks:** Operators whose front end preserves the Host header and rewrites
redirects keyed on the public host must narrow that rule to Master-pointing
redirects, and the documentation change needs to say so explicitly.
**Constraints:** With `spark.ui.reverseProxyUrl` unset, proxied `Location`
headers must stay exactly as they were before this PR. Master-originated kill,
hold and resume redirects keep their declared `/` target.
**Exclusions:**
- Fixing how `ProxyRedirectHandler` handles an absolute
`spark.ui.proxyBase`, which was already broken before this PR when
`spark.ui.proxyRedirectUri` is combined with an absolute reverseProxyUrl.
**Success:** With `spark.ui.reverseProxyUrl=/path/to/spark` and
`spark.ui.proxyRedirectUri=https://mydomain.com`, a proxied driver redirect to
`/jobs/` resolves to `https://mydomain.com/path/to/spark/proxy/<id>/jobs/`,
with the prefix exactly once. Without `spark.ui.proxyRedirectUri` and with
`spark.ui.reverseProxyUrl` set, proxied redirects point at
`<reverseProxyUrl>/proxy/<id>/...`. With `spark.ui.reverseProxyUrl` unset,
proxied `Location` headers are unchanged from before this PR. The
`spark.ui.reverseProxyUrl` documentation states which redirects Spark rewrites
and which the front end must rewrite.
**Verification:**
- **Regression:** Drive a proxied redirect through the Master proxy with a
relative reverseProxyUrl and `spark.ui.proxyRedirectUri` configured, and assert
that the final `Location` carries the front-end prefix exactly once.
- **Behavior:** Drive a proxied redirect with an absolute reverseProxyUrl
and no `spark.ui.proxyRedirectUri`, and assert that the `Location` is re-rooted
onto the reverseProxyUrl.
- **Compatibility:** With no reverseProxyUrl configured, assert that proxied
`Location` headers keep the host-rooted form they had before this PR, and that
Master kill redirects still target the root.
- **Inspection:** Confirm the `spark.ui.reverseProxyUrl` documentation
describes the Master rewriting proxied redirects, and limits front-end redirect
rewriting to redirects that point at the Master.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]