This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5577-d5f5e12fb6879f15dbcf0c9cf6aaae3b532784e6 in repository https://gitbox.apache.org/repos/asf/texera.git
commit fa5fcbb60b6f0a305a21635e2560ca0b04b823e2 Author: Sarah Asad <[email protected]> AuthorDate: Fri Jun 12 13:38:25 2026 -0700 feat: Make Python Virtual Environment Persistent: Add Environments to Left Panel (#5577) <!-- 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 introduces persistent Python Virtual Environments (PVEs) by moving them out of the Computing Unit (CU) lifecycle and storing them in the database. Previously, PVEs were managed through Computing Units and existed only within the CU they were created in. As a result, PVEs were lost when the corresponding CU was terminated. This PR adds a new `virtual_environments` table to persist PVE configurations and introduces a dedicated dashboard interface for managing them. Users can now create, view, update, and delete their own Python virtual environments through a new "Environments" page in the dashboard sidebar. PVE definitions are stored as user-owned resources in the database and can be managed independently of Computing Units. <img width="1689" height="652" alt="Screenshot 2026-06-08 at 6 39 55 PM" src="https://github.com/user-attachments/assets/82711baf-b1ce-4cc6-9e84-a29a230ddc3a" /> <img width="1461" height="500" alt="Screenshot 2026-06-08 at 6 40 19 PM" src="https://github.com/user-attachments/assets/5bbbc360-0adf-401b-8ae8-6d9597d486c2" /> Note: This PR only introduces persistence for PVE metadata and configuration. Creating, updating, and deleting a PVE in this PR only affects the corresponding database records. The execution-time behavior of materializing and using these virtual environments inside a Computing Unit is not part of this change and will be introduced in a future PR. K8s configurations for this feature will be added in a future PR. ### 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. --> Related discussions and issues: #5360, #5361. ### 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 tests added to PveResourceSpec. ### 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 --- .../service/resource/AccessControlResource.scala | 12 +- .../apache/texera/AccessControlResourceSpec.scala | 7 + .../pythonvirtualenvironment/PveManager.scala | 75 +++++++ .../pythonvirtualenvironment/PveResource.scala | 110 +++++++++- .../pythonvirtualenvironment/PveResourceSpec.scala | 141 +++++++++++- frontend/src/app/app-routing.constant.ts | 1 + frontend/src/app/app-routing.module.ts | 5 + frontend/src/app/app.module.ts | 2 + .../dashboard/component/dashboard.component.html | 11 + .../component/dashboard.component.spec.ts | 4 +- .../app/dashboard/component/dashboard.component.ts | 2 + .../user/user-venv/user-venv.component.html | 183 ++++++++++++++++ .../user/user-venv/user-venv.component.scss | 99 +++++++++ .../user/user-venv/user-venv.component.ts | 243 +++++++++++++++++++++ .../virtual-environment.service.spec.ts | 84 +++++++ .../virtual-environment.service.ts | 22 ++ sql/changelog.xml | 4 + sql/texera_ddl.sql | 12 + sql/updates/24.sql | 39 ++++ 19 files changed, 1048 insertions(+), 8 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 0c90a6ce31..96b2d52624 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 @@ -23,7 +23,7 @@ import com.typesafe.scalalogging.LazyLogging import jakarta.annotation.security.{PermitAll, RolesAllowed} import jakarta.ws.rs.client.{Client, ClientBuilder, Entity} import jakarta.ws.rs.core._ -import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces} +import jakarta.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces} import org.apache.texera.auth.JwtParser.parseToken import org.apache.texera.auth.SessionUser import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField} @@ -233,6 +233,16 @@ class AccessControlResource extends LazyLogging { AccessControlResource.authorize(uriInfo, headers, Option(body).map(_.trim).filter(_.nonEmpty)) } + @PUT + @Path("/{path:.*}") + def authorizePut( + @Context uriInfo: UriInfo, + @Context headers: HttpHeaders, + body: String + ): Response = { + AccessControlResource.authorize(uriInfo, headers, Option(body).map(_.trim).filter(_.nonEmpty)) + } + @DELETE @Path("/{path:.*}") def authorizeDelete( 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 75f3bacb10..3dfe81d89d 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 @@ -291,4 +291,11 @@ class AccessControlResourceSpec response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode } + + it should "return OK for a PUT request when user has access" in { + val (uri, headers) = mockRequest("/pve/system", Some(testCU.getCuid.toString)) + val response = new AccessControlResource().authorizePut(uri, headers, """{"name":"env"}""") + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } } diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala index 2256798030..c82d252e43 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala @@ -26,6 +26,10 @@ import scala.jdk.CollectionConverters._ import scala.sys.process._ import java.util.Comparator import org.apache.texera.amber.config.PythonUtils +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.tables.daos.VirtualEnvironmentsDao +import org.apache.texera.dao.jooq.generated.tables.pojos.VirtualEnvironments +import org.jooq.JSONB /** * PveManager is responsible for managing Python Virtual Environments (PVEs) @@ -47,10 +51,15 @@ object PveManager { userPackages: Seq[String] ) + case class StoredPve(veid: Int, name: String, packagesJson: String) + private val VenvRoot: Path = Paths.get("/tmp/texera-pve/venvs") private val SafePveName = "^[A-Za-z0-9._-]+$".r + def isValidPveName(name: String): Boolean = + name != null && name.length <= 128 && SafePveName.pattern.matcher(name).matches() + private def cuidDir(cuid: Int, pveName: String): Path = { VenvRoot.resolve(cuid.toString).resolve(pveName) } @@ -213,6 +222,72 @@ object PveManager { queue.put(s"[PVE] Created new environment for cuid = $cuid") } + // Returns every PVE row belonging to the given user. + def listPvesForUser(uid: Int): List[StoredPve] = { + import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS + SqlServer + .getInstance() + .createDSLContext() + .selectFrom(VIRTUAL_ENVIRONMENTS) + .where(VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + .fetchInto(classOf[VirtualEnvironments]) + .asScala + .map { row => + val pkgsJson = Option(row.getPackages).map(_.data).getOrElse("{}") + StoredPve(row.getVeid, row.getName, pkgsJson) + } + .toList + } + + // Deletes a PVE row owned by `uid`. Returns true if a row was deleted, false if no + // matching row was found (either the veid doesn't exist or it belongs to another user). + def deletePveFromDb(veid: Int, uid: Int): Boolean = { + import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS + val rows = SqlServer + .getInstance() + .createDSLContext() + .deleteFrom(VIRTUAL_ENVIRONMENTS) + .where( + VIRTUAL_ENVIRONMENTS.VEID + .eq(veid) + .and(VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + ) + .execute() + rows > 0 + } + + // Updates an existing PVE row owned by `uid`. Returns true if a row was + // updated, false if no matching row was found. + def updatePve(veid: Int, uid: Int, name: String, packagesJson: String): Boolean = { + import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS + val rows = SqlServer + .getInstance() + .createDSLContext() + .update(VIRTUAL_ENVIRONMENTS) + .set(VIRTUAL_ENVIRONMENTS.NAME, name) + .set(VIRTUAL_ENVIRONMENTS.PACKAGES, JSONB.valueOf(packagesJson)) + .where( + VIRTUAL_ENVIRONMENTS.VEID + .eq(veid) + .and(VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + ) + .execute() + rows > 0 + } + + // Persists a PVE spec (name + packages JSON) for the given user. Returns the new veid. + def savePve(uid: Int, name: String, packagesJson: String): Int = { + val row = new VirtualEnvironments() + row.setUid(uid) + row.setName(name) + row.setPackages(JSONB.valueOf(packagesJson)) + val dao = new VirtualEnvironmentsDao( + SqlServer.getInstance().createDSLContext().configuration + ) + dao.insert(row) + row.getVeid + } + // returns list of PVE names and corresponding user packages for a given CU def getEnvironments(cuid: Int): List[PvePackageResponse] = { 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 ac07616d50..f404416731 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,7 +19,14 @@ package org.apache.texera.web.resource.pythonvirtualenvironment +import com.fasterxml.jackson.core.`type`.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import com.typesafe.scalalogging.LazyLogging +import io.dropwizard.auth.Auth +import org.apache.texera.auth.SessionUser import org.apache.texera.config.KubernetesConfig +import org.jooq.exception.DataAccessException import javax.ws.rs._ import javax.ws.rs.core.MediaType @@ -29,9 +36,18 @@ import javax.ws.rs.DELETE import javax.ws.rs.PathParam import javax.ws.rs.core.Response +object PveResource { + case class SavePvePayload(name: String, packages: Map[String, String]) + case class PveListItem(veid: Int, name: String, packages: Map[String, String]) + + private val mapper: ObjectMapper = new ObjectMapper().registerModule(DefaultScalaModule) + private val packagesType = new TypeReference[java.util.Map[String, String]] {} +} + @Path("/pve") @Consumes(Array(MediaType.APPLICATION_JSON)) -class PveResource { +class PveResource extends LazyLogging { + import PveResource._ // -------------------------------------------------- // Get system packages // -------------------------------------------------- @@ -47,13 +63,101 @@ class PveResource { Map("system" -> systemPkgs).asJava } catch { case e: Exception => - e.printStackTrace() + logger.error("Failed to get system packages", e) throw new InternalServerErrorException( "Failed to get system packages." ) } } + // -------------------------------------------------- + // List all PVEs for the current user from the database + // -------------------------------------------------- + @GET + @Path("/db") + @Produces(Array(MediaType.APPLICATION_JSON)) + def listPves(@Auth sessionUser: SessionUser): java.util.List[PveListItem] = { + PveManager + .listPvesForUser(sessionUser.getUid.intValue()) + .map { stored => + val packages: Map[String, String] = + try mapper.readValue(stored.packagesJson, packagesType).asScala.toMap + catch { case _: Throwable => Map.empty[String, String] } + PveListItem(stored.veid, stored.name, packages) + } + .asJava + } + + // -------------------------------------------------- + // Update a PVE row owned by the current user + // -------------------------------------------------- + @PUT + @Path("/db/{veid}") + @Produces(Array(MediaType.APPLICATION_JSON)) + def updatePve( + @PathParam("veid") veid: Int, + payload: SavePvePayload, + @Auth sessionUser: SessionUser + ): Response = { + val name = Option(payload.name).map(_.trim).getOrElse("") + if (!PveManager.isValidPveName(name)) { + return Response.status(Response.Status.BAD_REQUEST).entity("invalid name").build() + } + try { + val packagesJson = mapper.writeValueAsString(payload.packages) + val updated = PveManager.updatePve(veid, sessionUser.getUid.intValue(), name, packagesJson) + if (updated) Response.ok(Map("veid" -> veid).asJava).build() + else Response.status(Response.Status.NOT_FOUND).build() + } catch { + case e: DataAccessException if e.sqlState() == "23505" => + Response + .status(Response.Status.CONFLICT) + .entity(s"""An environment named "$name" already exists.""") + .build() + case e: Exception => + logger.error("Failed to update PVE", e) + throw new InternalServerErrorException(s"Failed to update PVE: ${e.getMessage}") + } + } + + // -------------------------------------------------- + // Delete a PVE row owned by the current user + // -------------------------------------------------- + @DELETE + @Path("/db/{veid}") + def deletePveFromDb(@PathParam("veid") veid: Int, @Auth sessionUser: SessionUser): Response = { + val deleted = PveManager.deletePveFromDb(veid, sessionUser.getUid.intValue()) + if (deleted) Response.noContent().build() + else Response.status(Response.Status.NOT_FOUND).build() + } + + // -------------------------------------------------- + // Save a PVE (name + packages) to the database for the current user + // -------------------------------------------------- + @POST + @Path("/db") + @Produces(Array(MediaType.APPLICATION_JSON)) + def savePve(payload: SavePvePayload, @Auth sessionUser: SessionUser): Response = { + val name = Option(payload.name).map(_.trim).getOrElse("") + if (!PveManager.isValidPveName(name)) { + return Response.status(Response.Status.BAD_REQUEST).entity("invalid name").build() + } + try { + val packagesJson = mapper.writeValueAsString(payload.packages) + val veid = PveManager.savePve(sessionUser.getUid.intValue(), name, packagesJson) + Response.status(Response.Status.CREATED).entity(Map("veid" -> veid).asJava).build() + } catch { + case e: DataAccessException if e.sqlState() == "23505" => + Response + .status(Response.Status.CONFLICT) + .entity(s"""An environment named "$name" already exists.""") + .build() + case e: Exception => + logger.error("Failed to save PVE", e) + throw new InternalServerErrorException(s"Failed to save PVE: ${e.getMessage}") + } + } + // -------------------------------------------------- // Fetch PVEs and Installed User Packages // -------------------------------------------------- @@ -80,7 +184,7 @@ class PveResource { Response.ok(pves).build() } catch { case e: Exception => - e.printStackTrace() + logger.error("Failed to get PVEs", e) throw new InternalServerErrorException(s"Failed to get PVEs: ${e.getMessage}") } } diff --git a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala index 92e83615c9..f0de0fa24a 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala @@ -19,25 +19,56 @@ package org.apache.texera.web.resource.pythonvirtualenvironment -import org.scalatest.BeforeAndAfterEach +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.apache.texera.web.resource.pythonvirtualenvironment.PveResource.SavePvePayload +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import java.nio.file.{Files, Path, Paths} +import java.util.UUID import java.util.concurrent.LinkedBlockingQueue +import javax.ws.rs.core.Response import scala.jdk.CollectionConverters._ -class PveResourceSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach { +class PveResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { private val testCuid = 256 + private val testUid = 8000 + scala.util.Random.nextInt(1000) private var testPveName: String = _ private var testRoot: Path = _ private var queue: LinkedBlockingQueue[String] = _ + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + val userDao = new UserDao(getDSLContext.configuration()) + val user = new User + user.setUid(testUid) + user.setName("pve_resource_spec_user") + user.setEmail(s"user_${UUID.randomUUID()}@example.com") + user.setPassword("password") + userDao.insert(user) + } + + override protected def afterAll(): Unit = shutdownDB() + override protected def beforeEach(): Unit = { testPveName = s"testenv${System.currentTimeMillis()}" testRoot = Paths.get("/tmp/texera-pve/venvs").resolve(testCuid.toString) queue = new LinkedBlockingQueue[String]() + getDSLContext + .deleteFrom(VIRTUAL_ENVIRONMENTS) + .where(VIRTUAL_ENVIRONMENTS.UID.eq(testUid)) + .execute() } override protected def afterEach(): Unit = { @@ -173,4 +204,110 @@ class PveResourceSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach PveManager.getPythonBin(testCuid, "name with spaces") shouldBe None PveManager.getPythonBin(testCuid, "name;rm") shouldBe None } + + "PveManager.savePve + listPvesForUser" should "round-trip a row for the owning user" in { + val veid = PveManager.savePve(testUid, "env-a", """{"numpy":"==1.26.0"}""") + veid should be > 0 + + val rows = PveManager.listPvesForUser(testUid) + rows.map(_.name) should contain("env-a") + val row = rows.find(_.veid == veid).get + row.name shouldBe "env-a" + row.packagesJson should include(""""numpy"""") + row.packagesJson should include(""""==1.26.0"""") + } + + "PveManager.updatePve" should "mutate an owned row and refuse rows owned by someone else" in { + val veid = PveManager.savePve(testUid, "env-b", "{}") + + PveManager.updatePve(veid, testUid, "env-b-renamed", """{"pandas":""}""") shouldBe true + + val updated = PveManager.listPvesForUser(testUid).find(_.veid == veid).get + updated.name shouldBe "env-b-renamed" + updated.packagesJson should include(""""pandas"""") + + val otherUid = testUid + 1 + PveManager.updatePve(veid, otherUid, "hijacked", "{}") shouldBe false + PveManager.listPvesForUser(testUid).find(_.veid == veid).get.name shouldBe "env-b-renamed" + } + + "PveManager.deletePveFromDb" should "remove an owned row and return false for missing veids" in { + val veid = PveManager.savePve(testUid, "env-c", "{}") + + PveManager.deletePveFromDb(veid, testUid) shouldBe true + PveManager.listPvesForUser(testUid).map(_.veid) should not contain veid + + PveManager.deletePveFromDb(veid, testUid) shouldBe false + PveManager.deletePveFromDb(-1, testUid) shouldBe false + } + + // Builds a SessionUser carrying testUid so resource-layer methods can read + // the owning user without going through real JWT auth. + private def sessionUser: SessionUser = { + val user = new User + user.setUid(testUid) + new SessionUser(user) + } + + "PveResource.listPves" should "return every row owned by the current user" in { + PveManager.savePve(testUid, "env-1", """{"numpy":"==1.26.0"}""") + PveManager.savePve(testUid, "env-2", "{}") + + val items = new PveResource().listPves(sessionUser).asScala + items.map(_.name).toSet shouldBe Set("env-1", "env-2") + } + + "PveResource.savePve" should "create a new row and return 201" in { + val resp = + new PveResource().savePve(SavePvePayload("env-new", Map("numpy" -> "==1.26.0")), sessionUser) + resp.getStatus shouldBe Response.Status.CREATED.getStatusCode + } + + it should "return 400 for an invalid name" in { + val resp = + new PveResource().savePve(SavePvePayload("bad name with spaces", Map.empty), sessionUser) + resp.getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + } + + it should "return 409 when the user already has an env with that name" in { + PveManager.savePve(testUid, "env-dup", "{}") + val resp = new PveResource().savePve(SavePvePayload("env-dup", Map.empty), sessionUser) + resp.getStatus shouldBe Response.Status.CONFLICT.getStatusCode + } + + "PveResource.updatePve" should "rename an owned row and return 200" in { + val veid = PveManager.savePve(testUid, "env-original", "{}") + val resp = + new PveResource().updatePve(veid, SavePvePayload("env-renamed", Map.empty), sessionUser) + resp.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return 400 for an invalid name" in { + val resp = new PveResource().updatePve(1, SavePvePayload("bad name", Map.empty), sessionUser) + resp.getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + } + + it should "return 404 for a veid the user doesn't own" in { + val resp = new PveResource().updatePve(-1, SavePvePayload("env-x", Map.empty), sessionUser) + resp.getStatus shouldBe Response.Status.NOT_FOUND.getStatusCode + } + + it should "return 409 when renaming onto a name the user already uses" in { + PveManager.savePve(testUid, "env-existing", "{}") + val target = PveManager.savePve(testUid, "env-other", "{}") + val resp = + new PveResource().updatePve(target, SavePvePayload("env-existing", Map.empty), sessionUser) + resp.getStatus shouldBe Response.Status.CONFLICT.getStatusCode + } + + "PveResource.deletePveFromDb" should "delete an owned row and return 204" in { + val veid = PveManager.savePve(testUid, "env-todelete", "{}") + val resp = new PveResource().deletePveFromDb(veid, sessionUser) + resp.getStatus shouldBe Response.Status.NO_CONTENT.getStatusCode + } + + it should "return 404 for a veid the user doesn't own" in { + val resp = new PveResource().deletePveFromDb(-1, sessionUser) + resp.getStatus shouldBe Response.Status.NOT_FOUND.getStatusCode + } } diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index 6e06f72520..e0b2c9eab0 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -35,6 +35,7 @@ export const USER_WORKFLOW = `${USER}/workflow`; export const USER_DATASET = `${USER}/dataset`; export const USER_DATASET_CREATE = `${USER_DATASET}/create`; export const USER_COMPUTING_UNIT = `${USER}/compute`; +export const USER_PYTHON_VENV = `${USER}/python-venv`; export const USER_QUOTA = `${USER}/quota`; export const USER_DISCUSSION = `${USER}/discussion`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 8e5a44903e..78ccf0232c 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -25,6 +25,7 @@ import { UserQuotaComponent } from "./dashboard/component/user/user-quota/user-q import { UserProjectSectionComponent } from "./dashboard/component/user/user-project/user-project-section/user-project-section.component"; import { UserProjectComponent } from "./dashboard/component/user/user-project/user-project.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; +import { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component"; import { WorkspaceComponent } from "./workspace/component/workspace.component"; import { AboutComponent } from "./hub/component/about/about.component"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; @@ -128,6 +129,10 @@ routes.push({ path: "compute", component: UserComputingUnitComponent, }, + { + path: "python-venv", + component: UserVenvComponent, + }, { path: "quota", component: UserQuotaComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 5ddb97944a..524146cf75 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -193,6 +193,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox"; import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component"; +import { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component"; registerLocaleData(en); @@ -363,6 +364,7 @@ registerLocaleData(en); MarkdownDescriptionComponent, UserComputingUnitComponent, UserComputingUnitListItemComponent, + UserVenvComponent, ], providers: [ provideNzI18n(en_US), diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index 9014ea37e5..c01def869b 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -109,6 +109,17 @@ nzType="deployment-unit"></span> <span>Compute</span> </li> + <li + nz-menu-item + nz-tooltip="Manage saved Python virtual environments" + nzMatchRouter="true" + nzTooltipPlacement="right" + [routerLink]="USER_PYTHON_VENV"> + <span + nz-icon + nzType="python"></span> + <span>Environments</span> + </li> <li *ngIf="sidebarTabs.quota_enabled" nz-menu-item diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts index a53244b3cd..10352e3b57 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts @@ -283,7 +283,7 @@ describe("DashboardComponent", () => { }; fixture.detectChanges(); - // 6 "Your Work" links + 4 admin links + 1 about link = 11 - expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(11); + // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link = 12 + expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(12); }); }); diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index cec80766fc..01c05b0e52 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -38,6 +38,7 @@ import { USER_DATASET, USER_DISCUSSION, USER_PROJECT, + USER_PYTHON_VENV, USER_QUOTA, USER_WORKFLOW, } from "../../app-routing.constant"; @@ -109,6 +110,7 @@ export class DashboardComponent implements OnInit { protected readonly USER_WORKFLOW = USER_WORKFLOW; protected readonly USER_DATASET = USER_DATASET; protected readonly USER_COMPUTING_UNIT = USER_COMPUTING_UNIT; + protected readonly USER_PYTHON_VENV = USER_PYTHON_VENV; protected readonly USER_QUOTA = USER_QUOTA; protected readonly USER_DISCUSSION = USER_DISCUSSION; protected readonly ADMIN_USER = ADMIN_USER; diff --git a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.html b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.html new file mode 100644 index 0000000000..e29f9810af --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.html @@ -0,0 +1,183 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> + +<div class="section-container subsection-grid-container"> + <nz-card class="section-title"> + <h2 class="page-title">Environments</h2> + <div class="button-group"> + <button + nz-button + class="create-btn" + (click)="showPveModal()" + title="Create Environment"> + <i + nz-icon + nzType="file-add" + nzTheme="outline"></i> + <span>Create Environment</span> + </button> + </div> + </nz-card> + + <nz-card + class="section-list-container" + [nzBodyStyle]="{ height: '100%' }"> + <div class="python-env-page"> + <div + *ngIf="pves.length === 0" + class="python-env-page-empty"> + No environments yet. Click <strong>Create Environment</strong> to create one. + </div> + + <ul + *ngIf="pves.length > 0" + class="python-env-page-list"> + <li + *ngFor="let pve of pves; let i = index; trackBy: trackByVeid" + class="python-env-page-item" + (click)="openExistingPve(i)"> + <span class="python-env-name">{{ pve.name || "(unnamed)" }}</span> + <i + nz-icon + nzType="delete" + class="python-env-delete-icon" + nz-tooltip + nzTooltipTitle="Delete environment" + (click)="confirmDeletePve(i); $event.stopPropagation()" + role="button" + aria-label="Delete environment"> + </i> + </li> + </ul> + </div> + </nz-card> +</div> + +<nz-modal + nzWrapClassName="pve-modal" + nzClassName="pve-modal" + [nzVisible]="pveModalVisible" + nzTitle="Python Environment" + (nzOnCancel)="closePveModal()" + [nzFooter]="customFooter"> + <ng-template #customFooter> + <div class="footer-all"> + <button + nz-button + nzType="default" + (click)="closePveModal()"> + Close + </button> + <button + nz-button + nzType="primary" + [disabled]="!currentDraft?.name?.trim()" + [nzLoading]="saving" + (click)="saveEnvironment()"> + Save + </button> + </div> + </ng-template> + + <ng-container *nzModalContent> + <div + *ngIf="currentDraft as pve" + class="ve-form"> + <div class="fieldRow"> + <label class="fieldLabel">Virtual Environment Name</label> + <input + nz-input + class="fieldInput" + placeholder="Environment Name" + [(ngModel)]="pve.name" /> + </div> + + <div class="new-packages-section"> + <div + class="package-row user-package-header-row" + *ngIf="pve.newPackages.length > 0"> + <div class="user-package-inputs"> + <div class="package-column-label">Package</div> + <div></div> + <div class="package-column-label">Version</div> + <div></div> + </div> + </div> + + <div + *ngFor="let pkg of pve.newPackages; let i = index" + class="package-row"> + <div class="user-package-inputs"> + <div class="field"> + <input + nz-input + placeholder="Package Name" + [(ngModel)]="pve.newPackages[i].name" /> + </div> + <div class="field operator operator-select"> + <nz-select + nzPlaceHolder="Select" + nzCentered + [(ngModel)]="pve.newPackages[i].versionOp"> + <nz-option + nzValue="==" + nzLabel="=="></nz-option> + <nz-option + nzValue=">=" + nzLabel=">="></nz-option> + <nz-option + nzValue="<=" + nzLabel="<="></nz-option> + </nz-select> + </div> + <div class="field"> + <input + nz-input + placeholder="Package Version" + [(ngModel)]="pve.newPackages[i].version" /> + </div> + <button + nz-button + nzType="default" + nzShape="circle" + nzDanger + [class.highlighted-btn]="pkg.deleteToggle" + (click)="togglePackageDelete(pkg)"> + <i + nz-icon + nzType="delete"></i> + </button> + </div> + </div> + </div> + + <div class="add-btn"> + <button + nz-button + nzType="primary" + nzShape="circle" + (click)="addPackage()"> + <i + nz-icon + nzType="plus"></i> + </button> + </div> + </div> + </ng-container> +</nz-modal> diff --git a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.scss b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.scss new file mode 100644 index 0000000000..141c2a980d --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.scss @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +@import "../../section-style"; +@import "../../button-style"; + +.subsection-grid-container { + min-width: 100%; + width: 100%; + min-height: 100%; + height: 100%; +} + +.python-env-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.python-env-delete-icon { + flex-shrink: 0; + color: rgba(0, 0, 0, 0.55); + cursor: pointer; + + &:hover { + color: #ff4d4f; + } +} + +.python-env-page { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + + .python-env-page-empty { + padding: 24px; + color: rgba(0, 0, 0, 0.55); + text-align: center; + border: 1px dashed #d9d9d9; + border-radius: 4px; + } + + .python-env-page-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + } + + .python-env-page-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 16px 20px; + box-sizing: border-box; + background: #ffffff; + border: 1px solid #eef0f3; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + cursor: pointer; + transition: + background 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + background: #fafafa; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); + } + + .python-env-name { + font-size: 15px; + font-weight: 500; + } + } +} diff --git a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.ts b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.ts new file mode 100644 index 0000000000..0e84d3a298 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.ts @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, OnInit } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { NgFor, NgIf } from "@angular/common"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; + +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzInputDirective } from "ng-zorro-antd/input"; +import { NzModalComponent, NzModalContentDirective, NzModalService } from "ng-zorro-antd/modal"; +import { NzOptionComponent, NzSelectComponent } from "ng-zorro-antd/select"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; + +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { + UserPveRecord, + WorkflowPveService, +} from "../../../../workspace/service/virtual-environment/virtual-environment.service"; + +type PveUserPackageRow = { + name: string; + versionOp: "==" | ">=" | "<="; + version: string; + deleteToggle?: boolean; +}; + +type PveDraft = { + veid?: number; + name: string; + newPackages: PveUserPackageRow[]; +}; + +@UntilDestroy() +@Component({ + selector: "texera-user-venv", + templateUrl: "./user-venv.component.html", + styleUrls: ["./user-venv.component.scss"], + imports: [ + NgIf, + NgFor, + FormsModule, + NzButtonComponent, + NzCardComponent, + NzIconDirective, + NzInputDirective, + NzModalComponent, + NzModalContentDirective, + NzSelectComponent, + NzOptionComponent, + NzTooltipDirective, + ], +}) +export class UserVenvComponent implements OnInit { + // The user's PVEs (fetched from the DB), rendered as the page list. + pves: PveDraft[] = []; + + // The single PVE currently being edited in the modal. Null when modal is closed. + currentDraft: PveDraft | null = null; + + pveModalVisible = false; + saving = false; + + constructor( + private workflowPveService: WorkflowPveService, + private notificationService: NotificationService, + private modalService: NzModalService + ) {} + + ngOnInit(): void { + this.refreshPves(); + } + + confirmDeletePve(index: number): void { + const target = this.pves[index]; + if (!target) return; + const name = target.name || "(unnamed)"; + this.modalService.confirm({ + nzTitle: `Delete environment "${name}"?`, + nzContent: "This permanently removes the environment from the database.", + nzOkText: "Delete", + nzOkDanger: true, + nzOnOk: () => this.deletePve(index), + }); + } + + private refreshPves(): void { + this.workflowPveService + .listUserPves() + .pipe(untilDestroyed(this)) + .subscribe({ + next: records => { + this.pves = records.map(record => this.recordToDraft(record)); + }, + error: (err: unknown) => { + console.error("Failed to fetch Python environments", err); + this.notificationService.error("Failed to fetch Python environments."); + }, + }); + } + + private recordToDraft(record: UserPveRecord): PveDraft { + const newPackages: PveUserPackageRow[] = Object.entries(record.packages ?? {}).map(([name, raw]) => { + const match = raw?.match?.(/^(==|>=|<=)(.*)$/); + return { + name, + versionOp: (match ? match[1] : "==") as "==" | ">=" | "<=", + version: match ? match[2] : raw ?? "", + }; + }); + return { + veid: record.veid, + name: record.name, + newPackages, + }; + } + + showPveModal(): void { + this.currentDraft = { + name: "", + newPackages: [], + }; + this.pveModalVisible = true; + } + + openExistingPve(index: number): void { + const source = this.pves[index]; + if (!source) return; + this.currentDraft = { + veid: source.veid, + name: source.name, + newPackages: source.newPackages.map(p => ({ ...p })), + }; + this.pveModalVisible = true; + } + + closePveModal(): void { + this.pveModalVisible = false; + this.currentDraft = null; + } + + addPackage(): void { + this.currentDraft?.newPackages.push({ name: "", versionOp: "==", version: "" }); + } + + togglePackageDelete(pkg: PveUserPackageRow): void { + pkg.deleteToggle = !pkg.deleteToggle; + } + + saveEnvironment(): void { + const draft = this.currentDraft; + if (!draft) return; + + const trimmedName = draft.name.trim(); + if (!trimmedName) { + this.notificationService.error("Environment name is required."); + return; + } + + const conflict = this.pves.find(p => p.name.trim() === trimmedName && p.veid !== draft.veid); + if (conflict) { + this.notificationService.error(`An environment named "${trimmedName}" already exists.`); + return; + } + + const packages: Record<string, string> = {}; + for (const row of draft.newPackages) { + if (row.deleteToggle) continue; + const pkgName = row.name.trim(); + if (!pkgName) continue; + const pkgVersion = (row.version ?? "").trim(); + if (packages[pkgName] !== undefined) { + this.notificationService.error(`Duplicate package "${pkgName}".`); + return; + } + packages[pkgName] = pkgVersion ? `${row.versionOp}${pkgVersion}` : ""; + } + + this.saving = true; + const request$ = + draft.veid === undefined + ? this.workflowPveService.savePve(trimmedName, packages) + : this.workflowPveService.updateUserPve(draft.veid, trimmedName, packages); + + request$.pipe(untilDestroyed(this)).subscribe({ + next: () => { + this.saving = false; + this.notificationService.success(`Saved environment "${trimmedName}".`); + this.closePveModal(); + this.refreshPves(); + }, + error: (err: unknown) => { + this.saving = false; + console.error("Failed to save PVE", err); + this.notificationService.error("Failed to save Python environment."); + }, + }); + } + + trackByVeid(_: number, pve: PveDraft): number | undefined { + return pve.veid; + } + + deletePve(index: number): void { + const target = this.pves[index]; + if (!target || target.veid === undefined) return; + + const veid = target.veid; + const name = target.name || "(unnamed)"; + + this.workflowPveService + .deleteUserPve(veid) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success(`Deleted environment "${name}".`); + this.refreshPves(); + }, + error: (err: unknown) => { + console.error("Failed to delete PVE", err); + this.notificationService.error("Failed to delete Python environment."); + }, + }); + } +} diff --git a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.spec.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.spec.ts new file mode 100644 index 0000000000..c194c115cc --- /dev/null +++ b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.spec.ts @@ -0,0 +1,84 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { UserPveRecord, WorkflowPveService } from "./virtual-environment.service"; +import { commonTestProviders } from "../../../common/testing/test-utils"; + +describe("WorkflowPveService", () => { + let service: WorkflowPveService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [WorkflowPveService, ...commonTestProviders], + }); + service = TestBed.inject(WorkflowPveService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("savePve() POSTs to /pve/db with name + packages and returns the new veid", () => { + const packages = { numpy: "==1.26.0" }; + service.savePve("env-a", packages).subscribe(resp => { + expect(resp.veid).toBe(42); + }); + + const req = httpTestingController.expectOne("/pve/db"); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ name: "env-a", packages }); + req.flush({ veid: 42 }); + }); + + it("updateUserPve() PUTs to /pve/db/{veid} with name + packages", () => { + const packages = { pandas: "" }; + service.updateUserPve(7, "env-b", packages).subscribe(resp => { + expect(resp.veid).toBe(7); + }); + + const req = httpTestingController.expectOne("/pve/db/7"); + expect(req.request.method).toBe("PUT"); + expect(req.request.body).toEqual({ name: "env-b", packages }); + req.flush({ veid: 7 }); + }); + + it("listUserPves() GETs /pve/db and returns the array of records", () => { + const records: UserPveRecord[] = [{ veid: 1, name: "env-a", packages: { numpy: "==1.26.0" } }]; + service.listUserPves().subscribe(resp => { + expect(resp).toEqual(records); + }); + + const req = httpTestingController.expectOne("/pve/db"); + expect(req.request.method).toBe("GET"); + req.flush(records); + }); + + it("deleteUserPve() DELETEs /pve/db/{veid}", () => { + service.deleteUserPve(9).subscribe(); + + const req = httpTestingController.expectOne("/pve/db/9"); + expect(req.request.method).toBe("DELETE"); + req.flush(null); + }); +}); 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 d3108e4756..d399f643a0 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 @@ -31,10 +31,32 @@ export interface PvePackageResponse { userPackages: string[]; } +export interface UserPveRecord { + veid: number; + name: string; + packages: Record<string, string>; +} + @Injectable({ providedIn: "root" }) export class WorkflowPveService { constructor(private http: HttpClient) {} + savePve(name: string, packages: Record<string, string>): Observable<{ veid: number }> { + return this.http.post<{ veid: number }>("/pve/db", { name, packages }); + } + + updateUserPve(veid: number, name: string, packages: Record<string, string>): Observable<{ veid: number }> { + return this.http.put<{ veid: number }>(`/pve/db/${veid}`, { name, packages }); + } + + listUserPves(): Observable<UserPveRecord[]> { + return this.http.get<UserPveRecord[]>("/pve/db"); + } + + deleteUserPve(veid: number): Observable<void> { + return this.http.delete<void>(`/pve/db/${veid}`); + } + getAccessToken(): string | null { const token = AuthService.getAccessToken(); return token && token.trim().length > 0 ? token : null; diff --git a/sql/changelog.xml b/sql/changelog.xml index 4825321b20..39119f538b 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -29,6 +29,10 @@ <sqlFile path="sql/updates/23.sql"/> </changeSet> + <changeSet id="24" author="sarahasad23"> + <sqlFile path="sql/updates/24.sql"/> + </changeSet> + <!-- example changeSet <changeSet id="1" author="author"> <sqlFile path="sql/updates/1.sql"/> diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 83ed0abb50..26b009e420 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -76,6 +76,7 @@ DROP TABLE IF EXISTS site_settings CASCADE; DROP TABLE IF EXISTS computing_unit_user_access CASCADE; DROP TABLE IF EXISTS notebook CASCADE; DROP TABLE IF EXISTS workflow_notebook_mapping CASCADE; +DROP TABLE IF EXISTS virtual_environments CASCADE; -- ============================================ -- 4. Create PostgreSQL enum types @@ -213,6 +214,17 @@ CREATE TABLE IF NOT EXISTS workflow_computing_unit FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE ); +-- virtual_environments table +CREATE TABLE IF NOT EXISTS virtual_environments +( + veid SERIAL PRIMARY KEY, + uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + packages JSONB NOT NULL DEFAULT '{}'::jsonb, + FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE, + UNIQUE (uid, name) +); + -- workflow_executions CREATE TABLE IF NOT EXISTS workflow_executions ( diff --git a/sql/updates/24.sql b/sql/updates/24.sql new file mode 100644 index 0000000000..8d802dff22 --- /dev/null +++ b/sql/updates/24.sql @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +\c texera_db + +SET search_path TO texera_db; + +BEGIN; + +-- Adds the virtual_environments table, used to persist user-owned virtual +-- environment metadata (name + installed package versions) instead of +-- relying on the filesystem layout under /tmp/texera-pve/venvs. +CREATE TABLE IF NOT EXISTS virtual_environments +( + veid SERIAL PRIMARY KEY, + uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + packages JSONB NOT NULL DEFAULT '{}'::jsonb, + FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE, + UNIQUE (uid, name) +); + +COMMIT;
