This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new ec12c88860 feat: Add Python Virtual Environment Support: Add k8s
Gateway Configuration (#5138)
ec12c88860 is described below
commit ec12c8886003bd890d341949d8e53b838d712a57
Author: Sarah Asad <[email protected]>
AuthorDate: Thu May 28 15:27:21 2026 -0700
feat: Add Python Virtual Environment Support: Add k8s Gateway Configuration
(#5138)
<!--
Thanks for sending a pull request (PR)! Here are some tips for you:
1. If this is your first time, please read our contributor guidelines:
[Contributing to
Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
2. Ensure you have added or run the appropriate tests for your PR
3. If the PR is work in progress, mark it a draft on GitHub.
4. Please write your PR title to summarize what this PR proposes, we
are following Conventional Commits style for PR titles as well.
5. Be sure to keep the PR description updated to reflect all changes.
-->
### What changes were proposed in this PR?
<!--
Please clarify what changes you are proposing. The purpose of this
section
is to outline the changes. Here are some tips for you:
1. If you propose a new API, clarify the use case for a new API.
2. If you fix a bug, you can clarify why it is a bug.
3. If it is a refactoring, clarify what has been changed.
3. It would be helpful to include a before-and-after comparison using
screenshots or GIFs.
4. Please consider writing useful notes for better and faster reviews.
-->
This PR is an extension of PR
https://github.com/apache/texera/pull/4484,
https://github.com/apache/texera/pull/4902,
https://github.com/apache/texera/pull/5035, and #5069. It adds
Kubernetes gateway routing and access control configurations.
### Any related issues, documentation, discussions?
<!--
Please use this section to link other resources if not mentioned
already.
1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves
#1234`
or `Closes #1234`. If it is only related, simply mention the issue
number.
2. If there is design documentation, please add the link.
3. If there is a discussion in the mailing list, please add the link.
-->
This change is part of ongoing efforts to support environment isolation
and reproducibility within Texera. Related issue includes
https://github.com/apache/texera/issues/4296. This PR closes sub-issue
#5137.
### How was this PR tested?
<!--
If tests were added, say they were added here. Or simply mention that if
the PR
is tested with existing test cases. Make sure to include/update test
cases that
check the changes thoroughly including negative and positive cases if
possible.
If it was tested in a way different from regular unit tests, please
clarify how
you tested step by step, ideally copy and paste-able, so that other
reviewers can
test and check, and descendants can verify in the future. If tests were
not added,
please describe why they were not added and/or why it was difficult to
add.
-->
Tested manually and test added to AccessControlResourceSpec.
### Was this PR authored or co-authored using generative AI tooling?
<!--
If generative AI tooling has been used in the process of authoring this
PR,
please include the phrase: 'Generated-by: ' followed by the name of the
tool
and its version. If no, write 'No'.
Please refer to the [ASF Generative Tooling
Guidance](https://www.apache.org/legal/generative-tooling.html) for
details.
-->
Co-authored using: Claude Code (claude-opus-4-7)
---------
Co-authored-by: Kunwoo (Chris) <[email protected]>
---
.../service/resource/AccessControlResource.scala | 28 +++++++++--
.../apache/texera/AccessControlResourceSpec.scala | 58 ++++++++++++++++++++++
.../pythonvirtualenvironment/PveResource.scala | 11 ++--
.../PveWebsocketResource.scala | 4 +-
bin/computing-unit-master.dockerfile | 2 +
bin/k8s/templates/gateway-routes.yaml | 14 ++++++
.../computing-unit-selection.component.ts | 10 ++--
.../virtual-environment.service.ts | 11 ++--
8 files changed, 115 insertions(+), 23 deletions(-)
diff --git
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
index 0cd52f4919..d305bf8eb6 100644
---
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
+++
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
@@ -22,7 +22,7 @@ import com.fasterxml.jackson.module.scala.DefaultScalaModule
import com.typesafe.scalalogging.LazyLogging
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
import jakarta.ws.rs.core._
-import jakarta.ws.rs.{Consumes, GET, POST, Path, Produces}
+import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces}
import org.apache.texera.auth.JwtParser.parseToken
import org.apache.texera.auth.SessionUser
import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField}
@@ -43,6 +43,11 @@ object AccessControlResource extends LazyLogging {
private val wsapiWorkflowWebsocket: Regex =
""".*/wsapi/workflow-websocket.*""".r
private val apiExecutionsStats: Regex =
""".*/api/executions/[0-9]+/stats/[0-9]+.*""".r
private val apiExecutionsResultExport: Regex =
""".*/api/executions/result/export.*""".r
+ private val pveRoute: Regex =
"""^/?(?:auth/)?(?:api/|wsapi/)?pve(?:/.*)?$""".r
+ // Path patterns whose cuid lives in the URL path rather than the query
string.
+ private val pvePvesCuidPath: Regex =
"""^/?(?:auth/)?(?:api/|wsapi/)?pve/pves/([0-9]+)$""".r
+ private val pvePackagesCuidPath: Regex =
+ """^/?(?:auth/)?(?:api/|wsapi/)?pve/([0-9]+)/[^/]+/packages/.+$""".r
/**
* Authorize the request based on the path and headers.
@@ -60,7 +65,8 @@ object AccessControlResource extends LazyLogging {
logger.info(s"Authorizing request for path: $path")
path match {
- case wsapiWorkflowWebsocket() | apiExecutionsStats() |
apiExecutionsResultExport() =>
+ case wsapiWorkflowWebsocket() | apiExecutionsStats() |
apiExecutionsResultExport() |
+ pveRoute() =>
checkComputingUnitAccess(uriInfo, headers, bodyOpt)
case _ =>
logger.warn(s"No authorization logic for path: $path. Denying access.")
@@ -95,7 +101,14 @@ object AccessControlResource extends LazyLogging {
qToken.orElse(hToken).orElse(bToken).getOrElse("")
}
logger.info(s"token extracted from request $token")
- val cuid = queryParams.getOrElse("cuid", "")
+
+ val cuid = queryParams.get("cuid").filter(_.nonEmpty).getOrElse {
+ uriInfo.getPath match {
+ case pvePvesCuidPath(c) => c
+ case pvePackagesCuidPath(c) => c
+ case _ => ""
+ }
+ }
val cuidInt =
try {
cuid.toInt
@@ -213,6 +226,15 @@ class AccessControlResource extends LazyLogging {
logger.info("Request body: " + body)
AccessControlResource.authorize(uriInfo, headers,
Option(body).map(_.trim).filter(_.nonEmpty))
}
+
+ @DELETE
+ @Path("/{path:.*}")
+ def authorizeDelete(
+ @Context uriInfo: UriInfo,
+ @Context headers: HttpHeaders
+ ): Response = {
+ AccessControlResource.authorize(uriInfo, headers)
+ }
}
@Path("/chat")
diff --git
a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
index 751b51022f..75f3bacb10 100644
---
a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
+++
b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
@@ -233,4 +233,62 @@ class AccessControlResourceSpec
response.getHeaderString(HeaderField.UserName) shouldBe testUser1.getName
response.getHeaderString(HeaderField.UserEmail) shouldBe testUser1.getEmail
}
+
+ private def mockRequest(
+ path: String,
+ cuidQueryParam: Option[String]
+ ): (UriInfo, HttpHeaders) = {
+ val mockUriInfo = mock(classOf[UriInfo])
+ val mockHttpHeaders = mock(classOf[HttpHeaders])
+
+ val queryParams = new MultivaluedHashMap[String, String]()
+ cuidQueryParam.foreach(queryParams.add("cuid", _))
+
+ val requestHeaders = new MultivaluedHashMap[String, String]()
+ requestHeaders.add("Authorization", "Bearer " + token)
+
+ when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+ when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+ when(mockUriInfo.getPath).thenReturn(path)
+ when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+ when(mockHttpHeaders.getRequestHeader("Authorization"))
+ .thenReturn(util.Arrays.asList("Bearer " + token))
+
+ (mockUriInfo, mockHttpHeaders)
+ }
+
+ it should "return OK for /pve/system with cuid as query parameter" in {
+ val (uri, headers) = mockRequest("/pve/system",
Some(testCU.getCuid.toString))
+ val response = new AccessControlResource().authorizeGet(uri, headers)
+
+ response.getStatus shouldBe Response.Status.OK.getStatusCode
+ }
+
+ it should "return OK for /pve/pves/{cuid} (cuid extracted from path)" in {
+ val (uri, headers) = mockRequest(s"/pve/pves/${testCU.getCuid}", None)
+ val response = new AccessControlResource().authorizeDelete(uri, headers)
+
+ response.getStatus shouldBe Response.Status.OK.getStatusCode
+ }
+
+ it should "return OK for /pve/{cuid}/{pveName}/packages/{packageName} (cuid
extracted from path)" in {
+ val (uri, headers) =
mockRequest(s"/pve/${testCU.getCuid}/myenv/packages/numpy", None)
+ val response = new AccessControlResource().authorizeDelete(uri, headers)
+
+ response.getStatus shouldBe Response.Status.OK.getStatusCode
+ }
+
+ it should "return FORBIDDEN for a PVE path with no cuid in query or path" in
{
+ val (uri, headers) = mockRequest("/pve/no-cuid-anywhere", None)
+ val response = new AccessControlResource().authorizeGet(uri, headers)
+
+ response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+
+ it should "return FORBIDDEN for a non-PVE / non-whitelisted path" in {
+ val (uri, headers) = mockRequest("/random/garbage",
Some(testCU.getCuid.toString))
+ val response = new AccessControlResource().authorizeGet(uri, headers)
+
+ response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
index 80a4f68645..ac07616d50 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
@@ -19,6 +19,8 @@
package org.apache.texera.web.resource.pythonvirtualenvironment
+import org.apache.texera.config.KubernetesConfig
+
import javax.ws.rs._
import javax.ws.rs.core.MediaType
import scala.jdk.CollectionConverters._
@@ -37,11 +39,8 @@ class PveResource {
@Path("/system")
@Produces(Array(MediaType.APPLICATION_JSON))
def getSystemPackages: util.Map[String, util.List[String]] = {
+ val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled
try {
-
- // TODO: Support Kubernetes environment handling
- val isLocal = true
-
val systemPkgs =
PveManager.getSystemPackages(isLocal).toList.asJava
@@ -103,9 +102,9 @@ class PveResource {
def deletePackage(
@PathParam("cuid") cuid: Int,
@PathParam("pveName") pveName: String,
- @PathParam("packageName") packageName: String,
- @QueryParam("isLocal") isLocal: Boolean
+ @PathParam("packageName") packageName: String
): Response = {
+ val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled
val messages = PveManager.deletePackages(
cuid,
packageName,
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
index e21f91fada..efaa266caa 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
@@ -19,6 +19,8 @@
package org.apache.texera.web.resource.pythonvirtualenvironment
+import org.apache.texera.config.KubernetesConfig
+
import javax.websocket._
import javax.websocket.server.ServerEndpoint
import java.util.concurrent.LinkedBlockingQueue
@@ -41,7 +43,7 @@ class PveWebsocketResource {
val cuid = params.get("cuid").get(0).toInt
val pveName = params.get("pveName").get(0)
- val isLocal = params.get("isLocal").get(0).toBoolean
+ val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled
val action = params.getOrDefault("action",
java.util.List.of("create")).get(0)
val queue = new LinkedBlockingQueue[String]()
diff --git a/bin/computing-unit-master.dockerfile
b/bin/computing-unit-master.dockerfile
index 0d9d60b79f..aece464438 100644
--- a/bin/computing-unit-master.dockerfile
+++ b/bin/computing-unit-master.dockerfile
@@ -88,11 +88,13 @@ WORKDIR /texera/amber
COPY --from=build /texera/amber/requirements.txt /tmp/requirements.txt
COPY --from=build /texera/amber/operator-requirements.txt
/tmp/operator-requirements.txt
+COPY --from=build /texera/amber/system-requirements-lock.txt
/tmp/system-requirements-lock.txt
# Install Python runtime dependencies
RUN apt-get update && apt-get install -y \
python3-pip \
python3-dev \
+ python3-venv \
libpq-dev \
&& apt-get clean
diff --git a/bin/k8s/templates/gateway-routes.yaml
b/bin/k8s/templates/gateway-routes.yaml
index 55dc40f581..ab53c184e8 100644
--- a/bin/k8s/templates/gateway-routes.yaml
+++ b/bin/k8s/templates/gateway-routes.yaml
@@ -118,6 +118,20 @@ spec:
- group: gateway.envoyproxy.io
kind: Backend
name: texera-dynamic-backend
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /pve
+ filters:
+ - type: URLRewrite
+ urlRewrite:
+ path:
+ type: ReplacePrefixMatch
+ replacePrefixMatch: /api/pve
+ backendRefs:
+ - group: gateway.envoyproxy.io
+ kind: Backend
+ name: texera-dynamic-backend
---
# MinIO Route
{{- if .Values.minio.gateway.enabled }}
diff --git
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
index a2843a51bf..b9a41c5b5f 100644
---
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
+++
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
@@ -782,7 +782,6 @@ export class ComputingUnitSelectionComponent implements
OnInit {
getPVEs(): void {
const cuId = this.selectedComputingUnit!.computingUnit.cuid;
- const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
this.workflowPveService
.fetchPVEs(cuId)
@@ -802,7 +801,7 @@ export class ComputingUnitSelectionComponent implements
OnInit {
}));
this.workflowPveService
- .getSystemPackages(isLocal)
+ .getSystemPackages(cuId)
.pipe(untilDestroyed(this))
.subscribe({
next: installedResp => {
@@ -880,11 +879,10 @@ export class ComputingUnitSelectionComponent implements
OnInit {
const cuId = this.selectedComputingUnit!.computingUnit.cuid;
const env = this.pves[index];
const trimmedName = env.name.trim();
- const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
env.socket?.close();
- const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId,
trimmedName, isLocal, action, packages);
+ const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId,
trimmedName, action, packages);
const socket = new WebSocket(websocketUrl);
@@ -967,7 +965,6 @@ export class ComputingUnitSelectionComponent implements
OnInit {
createVirtualEnvironment(index: number): void {
const env = this.pves[index];
const trimmedName = env.name.trim();
- const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
if (!/^[a-zA-Z0-9]+$/.test(trimmedName)) {
this.notificationService.error("Environment name must contain only
letters and numbers.");
@@ -1066,7 +1063,6 @@ export class ComputingUnitSelectionComponent implements
OnInit {
private deleteUserPackages(index: number, onDone?: () => void): void {
const cuId = this.selectedComputingUnit!.computingUnit.cuid;
- const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
const pveName = this.pves[index].name.trim();
const packagesToDelete = [...this.pves[index].deletingPackages];
@@ -1094,7 +1090,7 @@ export class ComputingUnitSelectionComponent implements
OnInit {
const pkg = packagesToDelete[deleteIndex];
this.workflowPveService
- .deletePackage(cuId, pveName, pkg.name, isLocal)
+ .deletePackage(cuId, pveName, pkg.name)
.pipe(untilDestroyed(this))
.subscribe({
next: messages => {
diff --git
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
index 7788cba270..d3108e4756 100644
---
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
+++
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
@@ -49,8 +49,8 @@ export class WorkflowPveService {
return params;
}
- getSystemPackages(isLocal: boolean): Observable<PackageResponse> {
- const params = this.buildBaseParams();
+ getSystemPackages(cuid: number): Observable<PackageResponse> {
+ const params = this.buildBaseParams().set("cuid", cuid.toString());
return this.http.get<PackageResponse>("/pve/system", { params });
}
@@ -67,8 +67,8 @@ export class WorkflowPveService {
return this.http.delete(`/pve/pves/${cuid}`);
}
- deletePackage(cuid: number, pveName: string, packageName: string, isLocal:
boolean) {
- const params = this.buildBaseParams().set("isLocal", isLocal.toString());
+ deletePackage(cuid: number, pveName: string, packageName: string) {
+ const params = this.buildBaseParams();
return this.http.delete<string[]>(
`/pve/${cuid}/${encodeURIComponent(pveName)}/packages/${encodeURIComponent(packageName)}`,
@@ -76,7 +76,7 @@ export class WorkflowPveService {
);
}
- getPveWebSocketUrl(cuid: number, pveName: string, isLocal: boolean, action:
string, packages: string[] = []): string {
+ getPveWebSocketUrl(cuid: number, pveName: string, action: string, packages:
string[] = []): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const query = encodeURIComponent(JSON.stringify(packages));
@@ -88,7 +88,6 @@ export class WorkflowPveService {
`?packages=${query}` +
`&cuid=${cuid}` +
`&pveName=${encodeURIComponent(pveName)}` +
- `&isLocal=${isLocal}` +
`&action=${action}` +
tokenParam
);