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 0c90a6ce31f..96b2d52624f 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/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 2256798030b..658ac80b1d9 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.PythonVirtualEnvironmentsDao +import org.apache.texera.dao.jooq.generated.tables.pojos.PythonVirtualEnvironments +import org.jooq.JSONB /** * PveManager is responsible for managing Python Virtual Environments (PVEs) @@ -47,6 +51,8 @@ object PveManager { userPackages: Seq[String] ) + case class StoredPve(pveid: Int, name: String, packagesJson: String) + private val VenvRoot: Path = Paths.get("/tmp/texera-pve/venvs") private val SafePveName = "^[A-Za-z0-9._-]+$".r @@ -213,6 +219,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.PYTHON_VIRTUAL_ENVIRONMENTS + SqlServer + .getInstance() + .createDSLContext() + .selectFrom(PYTHON_VIRTUAL_ENVIRONMENTS) + .where(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + .fetchInto(classOf[PythonVirtualEnvironments]) + .asScala + .map { row => + val pkgsJson = Option(row.getPackages).map(_.data).getOrElse("{}") + StoredPve(row.getPveid, 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 pveid doesn't exist or it belongs to another user). + def deletePveFromDb(pveid: Int, uid: Int): Boolean = { + import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS + val rows = SqlServer + .getInstance() + .createDSLContext() + .deleteFrom(PYTHON_VIRTUAL_ENVIRONMENTS) + .where( + PYTHON_VIRTUAL_ENVIRONMENTS.PVEID + .eq(pveid) + .and(PYTHON_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(pveid: Int, uid: Int, name: String, packagesJson: String): Boolean = { + import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS + val rows = SqlServer + .getInstance() + .createDSLContext() + .update(PYTHON_VIRTUAL_ENVIRONMENTS) + .set(PYTHON_VIRTUAL_ENVIRONMENTS.NAME, name) + .set(PYTHON_VIRTUAL_ENVIRONMENTS.PACKAGES, JSONB.valueOf(packagesJson)) + .where( + PYTHON_VIRTUAL_ENVIRONMENTS.PVEID + .eq(pveid) + .and(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + ) + .execute() + rows > 0 + } + + // Persists a PVE spec (name + packages JSON) for the given user. Returns the new pveid. + def savePve(uid: Int, name: String, packagesJson: String): Int = { + val row = new PythonVirtualEnvironments() + row.setUid(uid) + row.setName(name) + row.setPackages(JSONB.valueOf(packagesJson)) + val dao = new PythonVirtualEnvironmentsDao( + SqlServer.getInstance().createDSLContext().configuration + ) + dao.insert(row) + row.getPveid + } + // 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 ac07616d509..3b97dbdda23 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,11 @@ 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 io.dropwizard.auth.Auth +import org.apache.texera.auth.SessionUser import org.apache.texera.config.KubernetesConfig import javax.ws.rs._ @@ -29,9 +34,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(pveid: Int, name: String, packages: Map[String, String]) +} + @Path("/pve") @Consumes(Array(MediaType.APPLICATION_JSON)) class PveResource { + import PveResource._ + + private val mapper: ObjectMapper = new ObjectMapper().registerModule(DefaultScalaModule) + private val packagesType = new TypeReference[java.util.Map[String, String]] {} // -------------------------------------------------- // Get system packages // -------------------------------------------------- @@ -54,6 +68,90 @@ class PveResource { } } + // -------------------------------------------------- + // 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.pveid, stored.name, packages) + } + .asJava + } + + // -------------------------------------------------- + // Update a PVE row owned by the current user + // -------------------------------------------------- + @PUT + @Path("/db/{pveid}") + @Produces(Array(MediaType.APPLICATION_JSON)) + def updatePve( + @PathParam("pveid") pveid: Int, + payload: SavePvePayload, + @Auth sessionUser: SessionUser + ): Response = { + val name = Option(payload.name).map(_.trim).getOrElse("") + if (name.isEmpty) { + return Response + .status(Response.Status.BAD_REQUEST) + .entity("name is required") + .build() + } + try { + val packagesJson = mapper.writeValueAsString(payload.packages) + val updated = PveManager.updatePve(pveid, sessionUser.getUid.intValue(), name, packagesJson) + if (updated) Response.ok(Map("pveid" -> pveid).asJava).build() + else Response.status(Response.Status.NOT_FOUND).build() + } catch { + case e: Exception => + e.printStackTrace() + throw new InternalServerErrorException(s"Failed to update PVE: ${e.getMessage}") + } + } + + // -------------------------------------------------- + // Delete a PVE row owned by the current user + // -------------------------------------------------- + @DELETE + @Path("/db/{pveid}") + def deletePveFromDb(@PathParam("pveid") pveid: Int, @Auth sessionUser: SessionUser): Response = { + val deleted = PveManager.deletePveFromDb(pveid, 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 (name.isEmpty) { + return Response + .status(Response.Status.BAD_REQUEST) + .entity("name is required") + .build() + } + try { + val packagesJson = mapper.writeValueAsString(payload.packages) + val pveid = PveManager.savePve(sessionUser.getUid.intValue(), name, packagesJson) + Response.ok(Map("pveid" -> pveid).asJava).build() + } catch { + case e: Exception => + e.printStackTrace() + throw new InternalServerErrorException(s"Failed to save PVE: ${e.getMessage}") + } + } + // -------------------------------------------------- // Fetch PVEs and Installed User Packages // -------------------------------------------------- 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 92e83615c9f..e938cd21554 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,58 @@ package org.apache.texera.web.resource.pythonvirtualenvironment -import org.scalatest.BeforeAndAfterEach +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.jooq.generated.tables.pojos.User +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 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 + // Randomised to avoid colliding with unrelated specs that may seed uids in the + // same embedded postgres if they run in parallel. + 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() + // python_virtual_environments.uid has an FK to user(uid); seed one user + // for the DB-backed tests below to attach their rows to. + 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]() + // Clean any PVE rows left over from a prior test in this class. + getDSLContext + .deleteFrom(PYTHON_VIRTUAL_ENVIRONMENTS) + .where(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(testUid)) + .execute() } override protected def afterEach(): Unit = { @@ -173,4 +206,44 @@ 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 pveid = PveManager.savePve(testUid, "env-a", """{"numpy":"==1.26.0"}""") + pveid should be > 0 + + val rows = PveManager.listPvesForUser(testUid) + rows.map(_.name) should contain("env-a") + val row = rows.find(_.pveid == pveid).get + row.name shouldBe "env-a" + // Postgres JSONB normalises whitespace on read-back, so assert key/value separately + // rather than matching a literal JSON string. + 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 pveid = PveManager.savePve(testUid, "env-b", "{}") + + PveManager.updatePve(pveid, testUid, "env-b-renamed", """{"pandas":""}""") shouldBe true + + val updated = PveManager.listPvesForUser(testUid).find(_.pveid == pveid).get + updated.name shouldBe "env-b-renamed" + updated.packagesJson should include(""""pandas"""") + + // A different uid claiming the same pveid must not be able to update it. + val otherUid = testUid + 1 + PveManager.updatePve(pveid, otherUid, "hijacked", "{}") shouldBe false + PveManager.listPvesForUser(testUid).find(_.pveid == pveid).get.name shouldBe "env-b-renamed" + } + + "PveManager.deletePveFromDb" should "remove an owned row and return false for missing pveids" in { + val pveid = PveManager.savePve(testUid, "env-c", "{}") + + PveManager.deletePveFromDb(pveid, testUid) shouldBe true + PveManager.listPvesForUser(testUid).map(_.pveid) should not contain pveid + + // Already-deleted (or never-existed) pveid: deleter reports false, doesn't throw. + PveManager.deletePveFromDb(pveid, testUid) shouldBe false + PveManager.deletePveFromDb(-1, testUid) shouldBe false + } } diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index 6e06f725201..e0b2c9eab09 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 8e5a44903e3..ec2abafcb27 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 { UserPythonVenvComponent } from "./dashboard/component/user/user-python-venv/user-python-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: UserPythonVenvComponent, + }, { path: "quota", component: UserQuotaComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 511395365df..c5492bc728f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -192,6 +192,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 { UserPythonVenvComponent } from "./dashboard/component/user/user-python-venv/user-python-venv.component"; registerLocaleData(en); @@ -361,6 +362,7 @@ registerLocaleData(en); MarkdownDescriptionComponent, UserComputingUnitComponent, UserComputingUnitListItemComponent, + UserPythonVenvComponent, ], 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 9014ea37e5a..c01def869b5 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"> Compute +
  • + + Environments +
  • { }; 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 cec80766fca..01c05b0e52f 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-python-venv/user-python-venv.component.html b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html new file mode 100644 index 00000000000..cb08cd0efcd --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html @@ -0,0 +1,183 @@ + + +
    + +

    Python Venvs

    +
    + +
    +
    + + +
    +
    + No environments yet. Click Create Python Venv to create one. +
    + +
      +
    • + {{ pve.name || "(unnamed)" }} + + +
    • +
    +
    +
    +
    + + + + + + + +
    +
    + + +
    + +
    +
    +
    +
    Package
    +
    +
    Version
    +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss new file mode 100644 index 00000000000..141c2a980d6 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-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-python-venv/user-python-venv.component.ts b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts new file mode 100644 index 00000000000..c7a4b5a24dc --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-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 = { + pveid?: number; + name: string; + newPackages: PveUserPackageRow[]; +}; + +@UntilDestroy() +@Component({ + selector: "texera-user-python-venv", + templateUrl: "./user-python-venv.component.html", + styleUrls: ["./user-python-venv.component.scss"], + imports: [ + NgIf, + NgFor, + FormsModule, + NzButtonComponent, + NzCardComponent, + NzIconDirective, + NzInputDirective, + NzModalComponent, + NzModalContentDirective, + NzSelectComponent, + NzOptionComponent, + NzTooltipDirective, + ], +}) +export class UserPythonVenvComponent 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 { + pveid: record.pveid, + 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 = { + pveid: source.pveid, + 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.pveid !== draft.pveid); + if (conflict) { + this.notificationService.error(`An environment named "${trimmedName}" already exists.`); + return; + } + + const packages: Record = {}; + 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.pveid === undefined + ? this.workflowPveService.savePve(trimmedName, packages) + : this.workflowPveService.updateUserPve(draft.pveid, 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."); + }, + }); + } + + trackByIndex(index: number): number { + return index; + } + + deletePve(index: number): void { + const target = this.pves[index]; + if (!target || target.pveid === undefined) return; + + const pveid = target.pveid; + const name = target.name || "(unnamed)"; + + this.workflowPveService + .deleteUserPve(pveid) + .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.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts index d3108e47564..6fbf2a7eb64 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 { + pveid: number; + name: string; + packages: Record; +} + @Injectable({ providedIn: "root" }) export class WorkflowPveService { constructor(private http: HttpClient) {} + savePve(name: string, packages: Record): Observable<{ pveid: number }> { + return this.http.post<{ pveid: number }>("/pve/db", { name, packages }); + } + + updateUserPve(pveid: number, name: string, packages: Record): Observable<{ pveid: number }> { + return this.http.put<{ pveid: number }>(`/pve/db/${pveid}`, { name, packages }); + } + + listUserPves(): Observable { + return this.http.get("/pve/db"); + } + + deleteUserPve(pveid: number): Observable { + return this.http.delete(`/pve/db/${pveid}`); + } + getAccessToken(): string | null { const token = AuthService.getAccessToken(); return token && token.trim().length > 0 ? token : null; diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 83ed0abb503..939c2943529 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 python_virtual_environments CASCADE; -- ============================================ -- 4. Create PostgreSQL enum types @@ -213,6 +214,16 @@ CREATE TABLE IF NOT EXISTS workflow_computing_unit FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE ); +-- python_virtual_environments table +CREATE TABLE IF NOT EXISTS python_virtual_environments +( + pveid 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 +); + -- 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 00000000000..518976d06f2 --- /dev/null +++ b/sql/updates/24.sql @@ -0,0 +1,38 @@ +/* + * 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 python_virtual_environments table, used to persist user-owned PVE +-- metadata (name + installed package versions) instead of relying on the +-- filesystem layout under /tmp/texera-pve/venvs. +CREATE TABLE IF NOT EXISTS python_virtual_environments +( + pveid 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 +); + +COMMIT;