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-7741-c927890160a8701c1b8ecb4fc4cc17711ea011ad in repository https://gitbox.apache.org/repos/asf/texera.git
commit ee06e4ba05f937f43041f7dc7faaca11d6759f9b Author: Ryan Zhang <[email protected]> AuthorDate: Wed Aug 19 22:55:59 2026 +0000 feat(notebook-migration): remove a workflow's notebook file from the Jupyter pod on delete (#7741) ### What changes were proposed in this PR? Removes a workflow's notebook file from the Jupyter pod when the notebook is deleted, so the pod's `work/` directory no longer accumulates notebooks for workflows the user has removed. Before #7671 this was self limiting: every workflow uploaded to the same `work/notebook.ipynb`, so there was only ever one file and it was overwritten on reuse. Now that each workflow uploads to `work/notebook_<wid>.ipynb`, the file survives both a notebook delete and a workflow delete, because those paths only touched the database. The database is authoritative for whether a notebook exists. The pod file is a per user artifact only the frontend can reach, since the notebook migration service targets one Jupyter per process under the per user pod model. So the file cleanup is best effort from the frontend after the authoritative database delete, and a failure is logged rather than surfaced. **`NotebookMigrationResource` (new `delete-notebook` endpoint)** - Adds `POST /notebook-migration/delete-notebook`, the counterpart to `set-notebook`: it takes a `notebookName`, validates it with the same `[A-Za-z0-9._-]+\.ipynb` pattern (blocking path traversal before any network call), and issues `DELETE /api/contents/work/<name>` against the Jupyter Contents API, bounded by a 2s connect and read timeout so a stalled pod cannot wedge the request thread. - A 204 or 200 reports `deleted: 1`. A 404 is treated as a no op with `deleted: 0`, so a workflow whose notebook was never uploaded still deletes cleanly, consistent with how the database delete reports `deleted: 0` when nothing was stored. Any other status is a 500. - Extracts the shared `jupyterUnavailableResponse` so the four endpoints that need a reachable Jupyter cannot drift in status or body. **`NotebookMigrationService` (frontend)** - Adds `deleteNotebookForWorkflow(wid)`, the single seam both delete paths call. It derives the filename from the wid, so the `notebook_<wid>.ipynb` convention lives only in the service that owns it, posts it to the new endpoint, and returns nothing: pod cleanup is best effort, so a failure is logged, not surfaced, and no caller acts on the outcome. The parameter is a concrete wid so it can never fall back to the shared default filename. **`JupyterPanelService` (panel delete button)** - `deleteJupyterNotebook()` captures the current wid up front (so a mid flight workflow switch cannot retarget the delete), then calls `deleteNotebookForWorkflow(wid)` after the database delete succeeds. The unsaved workflow path (wid undefined or the default 0) still resets local state only, since no file was ever uploaded for it. **`UserWorkflowComponent` (dashboard delete)** - Adds a private `cleanupNotebookFiles(wids)` that calls `deleteNotebookForWorkflow(wid)` per wid, and calls it from the success handler of both single delete (`deleteWorkflow`) and bulk delete (`handleConfirmDeleteSelectedWorkflows`). It runs only after the backend delete succeeds, so a failed delete leaves the pod file in place. A deleted workflow with no notebook produces a harmless 404. ### Any related issues, documentation, discussions? Closes #7737 Parent issue #4301 ### How was this PR tested? - `NotebookMigrationResourceSpec.scala`: `delete-notebook` issues a DELETE against the `work/<name>` contents path (verb and path pinned), reports `deleted=1` on 204 and on 200, treats 404 as `deleted=0`, returns 500 when Jupyter rejects the delete or is unreachable, and returns 400 on an invalid name, a missing or non string name, or a malformed body. - `notebook-migration.service.spec.ts`: `deleteNotebookForWorkflow` posts the wid-derived name to `delete-notebook`, swallows a transport failure without notifying, and makes no HTTP call when the feature flag is off. - `jupyter-panel.service.spec.ts`: the panel delete calls `deleteNotebookForWorkflow` with the current wid, does not touch the pod when the database delete fails or for the unsaved default wid, and no ops when the flag is off. - `user-workflow.component.spec.ts`: single delete cleans up wid 5, bulk delete cleans up each checked wid in order, and neither the no wid path nor a backend delete error touches the pod. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --- .../user-workflow/user-workflow.component.spec.ts | 19 ++- .../user/user-workflow/user-workflow.component.ts | 20 ++- .../jupyter-panel/jupyter-panel.service.spec.ts | 17 ++- .../service/jupyter-panel/jupyter-panel.service.ts | 12 +- .../notebook-migration.service.spec.ts | 36 +++++ .../notebook-migration.service.ts | 18 +++ .../resource/NotebookMigrationResource.scala | 153 ++++++++++++++++----- .../resource/NotebookMigrationResourceSpec.scala | 110 +++++++++++++-- 8 files changed, 323 insertions(+), 62 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts index db2c2317b8..25f544da39 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts @@ -654,9 +654,12 @@ describe("SavedWorkflowSectionComponent", () => { }); describe("deleteWorkflow", () => { - it("deletes an entry with a wid and removes it from the results", () => { + it("deletes an entry with a wid, removes it from the results, and cleans up its pod notebook", () => { const persist = TestBed.inject(WorkflowPersistService) as any; persist.deleteWorkflow = vi.fn().mockReturnValue(of(null)); + const cleanup = vi + .spyOn(TestBed.inject(NotebookMigrationService), "deleteNotebookForWorkflow") + .mockResolvedValue(undefined); const target = makeEntry(5, "to delete"); setEntries([target, makeEntry(6, "keep")]); @@ -664,15 +667,18 @@ describe("SavedWorkflowSectionComponent", () => { expect(persist.deleteWorkflow).toHaveBeenCalledWith([5]); expect(component.searchResultsComponent.entries.map(e => e.name)).toEqual(["keep"]); + expect(cleanup).toHaveBeenCalledWith(5); }); it("does nothing when the entry has no wid", () => { const persist = TestBed.inject(WorkflowPersistService) as any; persist.deleteWorkflow = vi.fn(); + const cleanup = vi.spyOn(TestBed.inject(NotebookMigrationService), "deleteNotebookForWorkflow"); component.deleteWorkflow(makeEntry(undefined, "no wid")); expect(persist.deleteWorkflow).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); }); }); @@ -732,9 +738,12 @@ describe("SavedWorkflowSectionComponent", () => { }); describe("handleConfirmDeleteSelectedWorkflows", () => { - it("deletes checked wids and keeps undefined-wid entries", () => { + it("deletes checked wids, keeps undefined-wid entries, and cleans up each pod notebook", () => { const persist = TestBed.inject(WorkflowPersistService) as any; persist.deleteWorkflow = vi.fn().mockReturnValue(of(null)); + const cleanup = vi + .spyOn(TestBed.inject(NotebookMigrationService), "deleteNotebookForWorkflow") + .mockResolvedValue(undefined); setEntries([ makeEntry(1, "a", true), makeEntry(2, "b", true), @@ -746,6 +755,7 @@ describe("SavedWorkflowSectionComponent", () => { expect(persist.deleteWorkflow).toHaveBeenCalledWith([1, 2]); expect(component.searchResultsComponent.entries.map(e => e.name)).toEqual(["c", "d"]); + expect(cleanup.mock.calls.map(c => c[0])).toEqual([1, 2]); }); it("early-returns when a checked entry has no wid", () => { @@ -758,15 +768,18 @@ describe("SavedWorkflowSectionComponent", () => { expect(persist.deleteWorkflow).not.toHaveBeenCalled(); }); - it("alerts on a deletion error", () => { + it("alerts on a deletion error and does not touch the pod", () => { const persist = TestBed.inject(WorkflowPersistService) as any; persist.deleteWorkflow = vi.fn().mockReturnValue(throwError(() => "delfail")); const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); + const cleanup = vi.spyOn(TestBed.inject(NotebookMigrationService), "deleteNotebookForWorkflow"); setEntries([makeEntry(1, "a", true)]); component.handleConfirmDeleteSelectedWorkflows(); expect(alertSpy).toHaveBeenCalledWith("delfail"); + // The backend delete failed, so the pod file must be left in place. + expect(cleanup).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts index 4bd6acda2b..fad38708e0 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts @@ -504,19 +504,32 @@ export class UserWorkflowComponent implements AfterViewInit, OnDestroy { */ public deleteWorkflow(entry: DashboardEntry): void { - if (entry.workflow.workflow.wid == undefined) { + const wid = entry.workflow.workflow.wid; + if (wid == undefined) { return; } this.workflowPersistService - .deleteWorkflow([entry.workflow.workflow.wid]) + .deleteWorkflow([wid]) .pipe(untilDestroyed(this)) .subscribe(_ => { this.searchResultsComponent.entries = this.searchResultsComponent.entries.filter( - workflowEntry => workflowEntry.workflow.workflow.wid !== entry.workflow.workflow.wid + workflowEntry => workflowEntry.workflow.workflow.wid !== wid ); + this.cleanupNotebookFiles([wid]); }); } + // Best-effort removal of the deleted workflows' notebook files from the Jupyter pod. + // The workflow delete already cascades the notebook DB rows, but the pod's per-workflow + // notebook_<wid>.ipynb only the frontend can reach, so clean it up here. Not awaited and + // never surfaced: a workflow with no notebook is a harmless 404, and an unreachable pod + // must not affect a delete that already succeeded. + private cleanupNotebookFiles(wids: number[]): void { + for (const wid of wids) { + void this.notebookMigrationService.deleteNotebookForWorkflow(wid); + } + } + /** * Verify Uploaded file name and upload the file */ @@ -696,6 +709,7 @@ export class UserWorkflowComponent implements AfterViewInit, OnDestroy { // Check if wid is defined and if it's not included in targetWids return entryWid === undefined || !targetWids.includes(entryWid); }); + this.cleanupNotebookFiles(targetWids); }, // TODO: fix this with notification component error: (err: unknown) => alert(err), diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts index cd4abcdabc..256e8f2cba 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -88,6 +88,9 @@ describe("JupyterPanelService", () => { setMapping: vi.fn(), getJupyterURL: vi.fn().mockResolvedValue("http://jupyter"), deleteNotebookAndMapping: vi.fn().mockReturnValue(of({ success: true, deleted: 1 })), + // In the base mock because every successful delete fires it; a missing stub would + // throw inside the delete subscription rather than failing a targeted assertion. + deleteNotebookForWorkflow: vi.fn().mockResolvedValue(undefined), }; mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; @@ -146,6 +149,13 @@ describe("JupyterPanelService", () => { expect(mockWorkflow.unhighlightLinks).toHaveBeenCalled(); }); + it("deleteJupyterNotebook removes the pod's copy for the current workflow", () => { + service.deleteJupyterNotebook(); + + // The service derives the filename from the wid, so the panel just passes the wid. + expect(mockNotebook.deleteNotebookForWorkflow).toHaveBeenCalledWith(1); + }); + it("deleteJupyterNotebook keeps the panel open and notifies on failure", () => { mockNotebook.deleteNotebookAndMapping.mockReturnValueOnce(throwError(() => new Error("boom"))); let visible: boolean | null = null; @@ -157,6 +167,8 @@ describe("JupyterPanelService", () => { expect(mockNotification.error).toHaveBeenCalled(); expect(visible).toBe(true); expect(mockNotebook.deleteMapping).not.toHaveBeenCalled(); + // The notebook is still stored, so its file must stay in the pod. + expect(mockNotebook.deleteNotebookForWorkflow).not.toHaveBeenCalled(); }); it("deleteJupyterNotebook only resets local state for the default wid 0 (no backend call)", () => { @@ -170,8 +182,10 @@ describe("JupyterPanelService", () => { service.deleteJupyterNotebook(); - // wid 0 is the unsaved default workflow, so no backend delete should fire. + // wid 0 is the unsaved default workflow, so neither backend delete should fire: + // nothing is stored and no notebook file was ever uploaded for it. expect(mockNotebook.deleteNotebookAndMapping).not.toHaveBeenCalled(); + expect(mockNotebook.deleteNotebookForWorkflow).not.toHaveBeenCalled(); expect(visible).toBe(false); expect(exists).toBe(false); }); @@ -715,6 +729,7 @@ describe("JupyterPanelService", () => { service.deleteJupyterNotebook(); expect(mockNotebook.deleteNotebookAndMapping).not.toHaveBeenCalled(); expect(mockNotebook.deleteMapping).not.toHaveBeenCalled(); + expect(mockNotebook.deleteNotebookForWorkflow).not.toHaveBeenCalled(); }); it("minimizeJupyterNotebookPanel does not flip visibility", () => { diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts index ecb5c90eca..2a1681155d 100644 --- a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -237,15 +237,13 @@ export class JupyterPanelService { } } - // Delete the current workflow's stored notebook from the backend, then hide the - // panel and clear all local notebook state. This is the user-initiated action - // behind the panel's delete button, and is distinct from the workflow-switch - // cleanup (hideAndClearLocalState), which must never touch the backend. + // Delete the current workflow's stored notebook from the migration database and its file + // from the Jupyter pod, then hide the panel and clear all local notebook state. public deleteJupyterNotebook(): void { if (!this.enabled) return; const wid = this.workflowActionService.getWorkflow().wid; - // Unsaved workflow (wid undefined or the default wid 0): nothing is persisted, - // and a delete POST with such a wid would 500, so just reset local state. + // Unsaved workflow (wid undefined or the default wid 0): nothing is persisted and no + // notebook file was uploaded for it (the upload path needs a wid) if (!wid) { this.hideAndClearLocalState(); this.jupyterNotebookExists.next(false); @@ -257,6 +255,8 @@ export class JupyterPanelService { this.hideAndClearLocalState(); this.jupyterNotebookExists.next(false); this.clearHighlights(); + // wid is captured above, so a mid-flight workflow switch can't retarget this. + void this.notebookMigrationService.deleteNotebookForWorkflow(wid); }, error: (err: unknown) => { // Keep the panel open on failure so the user sees the notebook wasn't removed. diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts index 05699cfb21..26fa39ee4a 100644 --- a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts @@ -142,6 +142,37 @@ describe("NotebookMigrationService", () => { expect(mockNotificationService.error).toHaveBeenCalledWith(expect.stringContaining("network down")); }); + // deleteNotebookForWorkflow + it("posts the wid-derived notebook name to delete-notebook", async () => { + const promise = service.deleteNotebookForWorkflow(1); + + const req = httpMock.expectOne(req => req.url.endsWith("/notebook-migration/delete-notebook")); + + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ notebookName: "notebook_1.ipynb" }); + + req.flush({ success: true, deleted: 1 }); + + await promise; + }); + + it("makes no HTTP call for wid 0, which would map to the shared default filename", async () => { + await service.deleteNotebookForWorkflow(0); + httpMock.expectNone(req => req.url.endsWith("/notebook-migration/delete-notebook")); + }); + + it("swallows the failure and shows no notification when the notebook file delete fails", async () => { + // Pod cleanup is best effort, so a failure is logged rather than surfaced: the + // database delete it follows has already succeeded. + const promise = service.deleteNotebookForWorkflow(1); + + const req = httpMock.expectOne(req => req.url.endsWith("/notebook-migration/delete-notebook")); + req.error(new ErrorEvent("Server error")); + + await promise; + expect(mockNotificationService.error).not.toHaveBeenCalled(); + }); + // jupyter URL methods (HttpClient so the JwtModule interceptor attaches the auth token) it("should return Jupyter URL when the request succeeds", async () => { const promise = service.getJupyterURL(); @@ -343,6 +374,11 @@ describe("NotebookMigrationService", () => { httpMock.expectNone(req => req.url.includes("/notebook-migration/set-notebook")); }); + it("deleteNotebookForWorkflow makes no HTTP call", async () => { + await service.deleteNotebookForWorkflow(1); + httpMock.expectNone(req => req.url.endsWith("/notebook-migration/delete-notebook")); + }); + it("getJupyterURL returns null without making an HTTP call", async () => { const result = await service.getJupyterURL(); expect(result).toBeNull(); diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts index 6fb73ba316..1cb5b92b8e 100644 --- a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts @@ -163,6 +163,24 @@ export class NotebookMigrationService { } } + // Remove a workflow's notebook file from the Jupyter pod. Takes a concrete wid so it can + // never fall back to the shared default filename and delete the wrong file; callers guard + // out unsaved workflows before calling. Best effort by design: the database rows are the + // source of truth for whether a workflow has a notebook, so a failure here is logged, not + // surfaced, and nothing acts on the outcome. + public async deleteNotebookForWorkflow(wid: number): Promise<void> { + if (!this.enabled) return; + if (!Number.isInteger(wid) || wid <= 0) return; + const jupyterAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/delete-notebook`; + const headers = new HttpHeaders({ "Content-Type": "application/json" }); + + try { + await firstValueFrom(this.http.post(jupyterAPIUrl, { notebookName: notebookFileName(wid) }, { headers })); + } catch (error) { + console.error("Error deleting notebook from pod: ", error); + } + } + public async getJupyterURL(): Promise<string | null> { if (!this.enabled) return null; try { diff --git a/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala b/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala index 17a0a989d7..1600c4c61a 100644 --- a/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala +++ b/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala @@ -56,6 +56,37 @@ object NotebookMigrationResource extends LazyLogging { mapper.createObjectNode().put("success", true).put("deleted", deleted) ) + private def jupyterUnavailableResponse: Response = + Response + .status(500) + .entity( + mapper.writeValueAsString( + mapper + .createObjectNode() + .put("success", false) + .put("message", "Cannot connect to Jupyter server") + ) + ) + .build() + + // Parse a request body into a JSON object. Returns Left(400) on malformed JSON or a + // non-object root so a bad request is reported as a client error. + private def parseBody(body: String): Either[Response, JsonNode] = { + val json = + try mapper.readTree(body) + catch { case NonFatal(_) => null } + if (json == null || !json.isObject) { + Left( + Response + .status(Response.Status.BAD_REQUEST) + .entity(errorJson("Request body must be a JSON object")) + .build() + ) + } else { + Right(json) + } + } + // Read the required integer `wid` from a request body. Returns Left(400) when the field is // missing or not an integer so the caller can short-circuit. Without this a missing wid NPEs // into a 500 and a non-integer wid silently coerces to 0 via asInt(). @@ -117,17 +148,7 @@ object NotebookMigrationResource extends LazyLogging { } if (!isJupyterAvailable(jupyterUrl)) { - return Response - .status(500) - .entity( - """ - { - "success": false, - "message": "Cannot connect to Jupyter server" - } - """ - ) - .build() + return jupyterUnavailableResponse } Response @@ -138,17 +159,7 @@ object NotebookMigrationResource extends LazyLogging { // Returns the URL of Jupyter def getJupyterURL(): Response = { if (!isJupyterAvailable(jupyterUrl)) { - return Response - .status(500) - .entity( - """ - { - "success": false, - "message": "Cannot connect to Jupyter server" - } - """ - ) - .build() + return jupyterUnavailableResponse } Response.ok(successUrlJson(jupyterUrl)).build() @@ -158,7 +169,10 @@ object NotebookMigrationResource extends LazyLogging { def setNotebook(body: String): Response = { var conn: HttpURLConnection = null try { - val json = mapper.readTree(body) + val json = parseBody(body) match { + case Left(badRequest) => return badRequest + case Right(j) => j + } val notebookName = json.get("notebookName").asText() val notebookData = json.get("notebookData") @@ -174,17 +188,7 @@ object NotebookMigrationResource extends LazyLogging { } if (!isJupyterAvailable(jupyterUrl)) { - return Response - .status(500) - .entity( - """ - { - "success": false, - "message": "Cannot connect to Jupyter server" - } - """ - ) - .build() + return jupyterUnavailableResponse } // Construct Jupyter API URL @@ -251,10 +255,72 @@ object NotebookMigrationResource extends LazyLogging { } } + // Delete the notebook file from Jupyter's work/ directory: + def deleteNotebook(body: String): Response = { + var conn: HttpURLConnection = null + try { + val json = parseBody(body) match { + case Left(badRequest) => return badRequest + case Right(j) => j + } + + // Read the name defensively + val notebookName = + Option(json.get("notebookName")).filter(_.isTextual).map(_.asText()).getOrElse("") + + if (!notebookName.matches("[A-Za-z0-9._-]+\\.ipynb")) { + return Response + .status(Response.Status.BAD_REQUEST) + .entity(errorJson(s"Invalid notebook name: $notebookName")) + .build() + } + + if (!isJupyterAvailable(jupyterUrl)) { + return jupyterUnavailableResponse + } + + val url = new URL(s"$jupyterUrl/api/contents/work/$notebookName") + conn = url.openConnection().asInstanceOf[HttpURLConnection] + + conn.setRequestMethod("DELETE") + conn.setConnectTimeout(2000) + conn.setReadTimeout(2000) + conn.setRequestProperty("Authorization", s"token $jupyterToken") + + val status = conn.getResponseCode + + // Jupyter answers 204 on a successful delete, or 200 when it echoes the deleted entry. + // A 404 means the file is already gone, which is the requested end state, so report it + // as a no-op (deleted=0) rather than an error: a workflow whose notebook was never + // uploaded must still delete cleanly. + if (status != 204 && status != 200 && status != 404) { + return Response + .status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(errorJson(s"Failed to delete notebook from Jupyter (status $status)")) + .build() + } + + Response.ok(successDeletedJson(if (status == 404) 0 else 1)).build() + + } catch { + case NonFatal(e) => + logger.error("Error deleting notebook from Jupyter", e) + Response + .status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(errorJson(e.getMessage)) + .build() + } finally { + if (conn != null) conn.disconnect() + } + } + // Store notebook + mapping in database def storeNotebookAndMapping(body: String, uid: java.lang.Integer): Response = { try { - val json = mapper.readTree(body) + val json = parseBody(body) match { + case Left(badRequest) => return badRequest + case Right(j) => j + } val wid: java.lang.Integer = readWid(json) match { case Left(badRequest) => return badRequest @@ -345,7 +411,10 @@ object NotebookMigrationResource extends LazyLogging { // Fetch notebook + mapping def fetchNotebookAndMapping(body: String, uid: java.lang.Integer): Response = { try { - val json = mapper.readTree(body) + val json = parseBody(body) match { + case Left(badRequest) => return badRequest + case Right(j) => j + } val wid: java.lang.Integer = readWid(json) match { case Left(badRequest) => return badRequest @@ -419,7 +488,10 @@ object NotebookMigrationResource extends LazyLogging { // is UNIQUE (one notebook per workflow), so wid alone identifies the row and vid is not needed. def deleteNotebookAndMapping(body: String, uid: java.lang.Integer): Response = { try { - val json = mapper.readTree(body) + val json = parseBody(body) match { + case Left(badRequest) => return badRequest + case Right(j) => j + } val wid: java.lang.Integer = readWid(json) match { case Left(badRequest) => return badRequest @@ -491,6 +563,13 @@ class NotebookMigrationResource extends LazyLogging { NotebookMigrationResource.setNotebook(body) } + @POST + @Path("/delete-notebook") + def deleteNotebook(body: String, @Auth user: SessionUser): Response = { + logger.info("Deleting notebook from Jupyter") + NotebookMigrationResource.deleteNotebook(body) + } + @POST @Path("/store-notebook-and-mapping") def storeNotebookAndMapping(body: String, @Auth user: SessionUser): Response = { diff --git a/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala b/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala index 15ad27eff1..90b24f5349 100644 --- a/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala +++ b/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala @@ -73,6 +73,10 @@ class NotebookMigrationResourceSpec private var writerUid: Integer = _ // holds WRITE access to testWid private var readerUid: Integer = _ // holds READ access to testWid + // Method and path of the last /api/contents request the fake Jupyter saw, so a test can pin + // the verb and URL a Jupyter call uses. A var is safe here because the spec runs sequentially. + private var lastContentsRequest: Option[(String, String)] = None + private val sampleNotebook = """{"cells":[{"cell_type":"code","metadata":{},"source":"print(1)"}]}""" private val sampleMapping = @@ -87,6 +91,7 @@ class NotebookMigrationResourceSpec workflowVersionDao = new WorkflowVersionDao(cfg) userDao = new UserDao(cfg) workflowUserAccessDao = new WorkflowUserAccessDao(cfg) + lastContentsRequest = None cleanup() val workflow = new Workflow @@ -163,6 +168,9 @@ class NotebookMigrationResourceSpec private def deletePayload(): String = s"""{"wid": $testWid}""" + private def deleteNotebookPayload(name: String = "notebook.ipynb"): String = + s"""{"notebookName": "$name"}""" + private val resource = new NotebookMigrationResource() private def sessionUser(uid: Integer): SessionUser = { @@ -194,11 +202,17 @@ class NotebookMigrationResourceSpec "/api/contents", (exchange: com.sun.net.httpserver.HttpExchange) => { exchange.getRequestBody.readAllBytes() - val body = "{}".getBytes("UTF-8") - exchange.sendResponseHeaders(contentsStatus, body.length) - val os = exchange.getResponseBody - os.write(body) - os.close() + lastContentsRequest = Some((exchange.getRequestMethod, exchange.getRequestURI.getPath)) + if (contentsStatus == 204) { + // 204 carries no body, so send the headers with a -1 length. + exchange.sendResponseHeaders(contentsStatus, -1) + } else { + val body = "{}".getBytes("UTF-8") + exchange.sendResponseHeaders(contentsStatus, body.length) + val os = exchange.getResponseBody + os.write(body) + os.close() + } } ) server.start() @@ -362,11 +376,11 @@ class NotebookMigrationResourceSpec getDSLContext.fetchCount(WORKFLOW_NOTEBOOK_MAPPING) shouldBe 1 } - it should "return 500 when the request body is malformed JSON" in { - // Exercises the NonFatal catch path in deleteNotebookAndMapping. + it should "return 400 when the request body is malformed JSON" in { + // Malformed input is a client error, caught by parseBody before the generic 500 handler. resource .deleteNotebookAndMapping("not json", sessionUser(writerUid)) - .getStatus shouldBe 500 + .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode } // -- wid validation --------------------------------------------------------- @@ -450,13 +464,15 @@ class NotebookMigrationResourceSpec resource.setNotebook(validNotebook, user).getStatus shouldBe 500 resource.getJupyterURL(user).getStatus shouldBe 500 resource.getJupyterIframeURL(null, user).getStatus shouldBe 500 + resource.deleteNotebook(deleteNotebookPayload(), user).getStatus shouldBe 500 } - it should "return 500 when the request body is malformed JSON" in { - // Exercises the NonFatal catch paths in setNotebook and fetchNotebookAndMapping. + it should "return 400 when the request body is malformed JSON" in { + // Malformed input is a client error: parseBody rejects it before any downstream work. val user = sessionUser(writerUid) - resource.setNotebook("not json", user).getStatus shouldBe 500 - resource.fetchNotebookAndMapping("not json", user).getStatus shouldBe 500 + val badRequest = Response.Status.BAD_REQUEST.getStatusCode + resource.setNotebook("not json", user).getStatus shouldBe badRequest + resource.fetchNotebookAndMapping("not json", user).getStatus shouldBe badRequest } it should "upload the notebook and return success when Jupyter accepts it" in { @@ -545,6 +561,76 @@ class NotebookMigrationResourceSpec } } + // -- deleteNotebook (Jupyter file) ------------------------------------------ + + "deleteNotebook" should "DELETE the notebook's contents path and report deleted=1" in { + withFakeJupyter(contentsStatus = 204) { + val name = s"notebook_$testWid.ipynb" + val resp = resource.deleteNotebook(deleteNotebookPayload(name), sessionUser(writerUid)) + resp.getStatus shouldBe Response.Status.OK.getStatusCode + resp.getEntity.toString should include("\"deleted\":1") + // Pins the verb and the work/ path, the two things that make this the counterpart + // of setNotebook's PUT rather than a delete of some other file. + lastContentsRequest shouldBe Some(("DELETE", s"/api/contents/work/$name")) + } + } + + it should "treat a 200 from Jupyter as a successful delete, reporting deleted=1" in { + // Some Jupyter versions answer 200 instead of 204 on a delete; both mean success. + withFakeJupyter(contentsStatus = 200) { + val resp = resource.deleteNotebook(deleteNotebookPayload(), sessionUser(writerUid)) + resp.getStatus shouldBe Response.Status.OK.getStatusCode + resp.getEntity.toString should include("\"deleted\":1") + } + } + + it should "treat a 404 from Jupyter as a no-op, reporting deleted=0" in { + // A workflow whose notebook was never uploaded must still delete cleanly. + withFakeJupyter(contentsStatus = 404) { + val resp = resource.deleteNotebook(deleteNotebookPayload(), sessionUser(writerUid)) + resp.getStatus shouldBe Response.Status.OK.getStatusCode + resp.getEntity.toString should include("\"deleted\":0") + } + } + + it should "return 500 when Jupyter rejects the delete" in { + withFakeJupyter(contentsStatus = 500) { + resource + .deleteNotebook(deleteNotebookPayload(), sessionUser(writerUid)) + .getStatus shouldBe 500 + } + } + + it should "reject a notebook name that is not a plain .ipynb filename with 400" in { + // Validated before any Jupyter call, so no server is needed. Covers path traversal, + // a wrong extension, and an embedded subpath. + Seq("../../etc/evil.ipynb", "notebook.txt", "work/notebook.ipynb").foreach { name => + withClue(s"name=$name: ") { + NotebookMigrationResource + .deleteNotebook(deleteNotebookPayload(name)) + .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + } + lastContentsRequest shouldBe None + } + } + + it should "return 400 when 'notebookName' is missing or not a string" in { + // A missing name must be a client error, not a 500 from null.asText(). + Seq("""{}""", """{"notebookName": 7}""").foreach { body => + withClue(s"body=$body: ") { + NotebookMigrationResource + .deleteNotebook(body) + .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + } + } + } + + it should "return 400 when the request body is malformed JSON" in { + resource + .deleteNotebook("not json", sessionUser(writerUid)) + .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + } + // -- setNotebook ------------------------------------------------------------ "setNotebook" should "reject a notebook name that is not a plain .ipynb filename with 400" in {
