From b2b65ca3c96aac2801f10ef6f8240792fb01e633 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:38:38 +0200 Subject: [PATCH 01/10] feat(actions): add and update bucket-event actions Platform API 1.145 added the bucket action source: an action that launches its pipeline when objects matching a marker file are created or deleted in the bucket behind a data link. tw had no way to create one. - 'actions add bucket --data-link-id --marker-file --events object:created,object:deleted' sends CreateActionRequest.bucket. All three are required: the API refuses a missing data link or marker file, and provisioning the S3 notification fails without an event. - 'actions update --marker-file/--events' sends a partial UpdateActionRequest.bucket. The API treats a null field as "leave it alone" and only refuses a blank marker file, so omitted options keep their stored value. The data link cannot change, as the API refuses it. The API answers 400 for a bucket block on another source, which tw would hide behind its generic update error, so tw refuses it up front. - 'actions view' shows the data link, bucket, marker file and events, and the action error, which is where a failed notification setup is reported. AbstractAddCmd gets a configureTrigger hook so each source subcommand adds its own trigger block, and 'actions view' gets per-source trigger rows. Bucket is the first source that needs either. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/actions/AddCmd.java | 4 +- .../tower/cli/commands/actions/UpdateCmd.java | 21 ++++ .../commands/actions/add/AbstractAddCmd.java | 5 + .../commands/actions/add/AddBucketCmd.java | 50 ++++++++ .../cli/responses/actions/ActionsView.java | 16 +++ .../tower/cli/actions/ActionsCmdTest.java | 108 ++++++++++++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/actions/add/AddBucketCmd.java diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java index cbec5379c..025a65acb 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java @@ -17,6 +17,7 @@ package io.seqera.tower.cli.commands.actions; import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.actions.add.AddBucketCmd; import io.seqera.tower.cli.commands.actions.add.AddGitHubCmd; import io.seqera.tower.cli.commands.actions.add.AddTowerCmd; import io.seqera.tower.cli.exceptions.ShowUsageException; @@ -30,7 +31,8 @@ description = "Add a pipeline action", subcommands = { AddGitHubCmd.class, - AddTowerCmd.class + AddTowerCmd.class, + AddBucketCmd.class, } ) public class AddCmd extends AbstractActionsCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java index 066011a6f..e4e6d6aab 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java @@ -26,11 +26,14 @@ import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.cli.utils.ModelHelper; import io.seqera.tower.model.ActionResponseDto; +import io.seqera.tower.model.ActionSource; +import io.seqera.tower.model.BucketActionRequest; import io.seqera.tower.model.UpdateActionRequest; import io.seqera.tower.model.WorkflowLaunchRequest; import picocli.CommandLine; import java.io.IOException; +import java.util.List; import java.util.Objects; import static io.seqera.tower.cli.utils.ModelHelper.coalesce; @@ -49,6 +52,12 @@ public class UpdateCmd extends AbstractActionsCmd { @CommandLine.Option(names = {"--new-name"}, description = "Updated action name. Must be unique per workspace. Names consist of alphanumeric, hyphen, and underscore characters.") public String newName; + @CommandLine.Option(names = {"--marker-file"}, description = "Marker file of a bucket action, relative to the data link path.") + public String markerFile; + + @CommandLine.Option(names = {"--events"}, split = ",", description = "Comma-separated bucket events that trigger a bucket action: object:created, object:deleted.") + public List events; + @CommandLine.Mixin public WorkspaceOptionalOptions workspace; @@ -106,6 +115,11 @@ protected Response exec() throws ApiException, IOException { request.setName(newName != null ? newName : actionName); request.setLaunch(workflowLaunchRequest); + if (markerFile != null || events != null) { + requireSource(action, ActionSource.bucket, "--marker-file and --events"); + request.setBucket(new BucketActionRequest().markerFile(markerFile).events(events)); + } + try { actionsApi().updateAction(action.getId(), request, wspId); } catch (Exception e) { @@ -126,4 +140,11 @@ protected Response exec() throws ApiException, IOException { return new ActionUpdate(actionName, workspaceRef(wspId), action.getId()); } + + // The API refuses a trigger block that does not match the action source, so say which options apply. + private static void requireSource(ActionResponseDto action, ActionSource source, String options) throws TowerException { + if (action.getSource() != source) { + throw new TowerException(String.format("Options %s apply only to %s actions, but action '%s' is a %s action", options, source, action.getName(), action.getSource())); + } + } } diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java index 553c83155..c14982693 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java @@ -95,6 +95,7 @@ protected Response exec() throws ApiException, IOException { request.setName(actionName); request.setSource(getSource()); request.setLaunch(workflowLaunchRequest); + configureTrigger(request); if (overwrite) tryDeleteAction(actionName, wspId); @@ -121,4 +122,8 @@ private void tryDeleteAction(String actionName, Long wspId) throws ApiException } protected abstract ActionSource getSource(); + + /** Sets the source-specific trigger configuration. GitHub and Seqera Platform actions have none. */ + protected void configureTrigger(CreateActionRequest request) { + } } diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/add/AddBucketCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/add/AddBucketCmd.java new file mode 100644 index 000000000..31c9d26c1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/actions/add/AddBucketCmd.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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. + */ + +package io.seqera.tower.cli.commands.actions.add; + +import io.seqera.tower.model.ActionSource; +import io.seqera.tower.model.BucketActionRequest; +import io.seqera.tower.model.CreateActionRequest; +import picocli.CommandLine; + +import java.util.List; + +@CommandLine.Command( + name = "bucket", + description = "Add a pipeline action triggered by cloud storage events" +) +public class AddBucketCmd extends AbstractAddCmd { + + @CommandLine.Option(names = {"--data-link-id"}, description = "Data link of the bucket to watch. It must be an explicitly created data link with credentials.", required = true) + public String dataLinkId; + + @CommandLine.Option(names = {"--marker-file"}, description = "Marker file, relative to the data link path, whose events trigger the action (e.g., incoming/.done or signals/*.complete).", required = true) + public String markerFile; + + @CommandLine.Option(names = {"--events"}, split = ",", description = "Comma-separated bucket events that trigger the action: object:created, object:deleted.", required = true) + public List events; + + @Override + protected ActionSource getSource() { + return ActionSource.bucket; + } + + @Override + protected void configureTrigger(CreateActionRequest request) { + request.setBucket(new BucketActionRequest().dataLinkId(dataLinkId).markerFile(markerFile).events(events)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java index c615dabf0..9dd51c2cf 100644 --- a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java +++ b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java @@ -24,6 +24,7 @@ import io.seqera.tower.cli.utils.ModelHelper; import io.seqera.tower.cli.utils.TableList; import io.seqera.tower.model.ActionResponseDto; +import io.seqera.tower.model.BucketActionConfig; import io.seqera.tower.model.WorkflowLaunchRequest; import java.io.PrintWriter; @@ -62,9 +63,11 @@ public void toString(PrintWriter out) { table.addRow("ID", formatActionId(action.getId(), baseWorkspaceUrl)); table.addRow("Name", action.getName()); table.addRow("Status", formatActionStatus(action.getStatus())); + if (action.getError() != null) table.addRow("Error", action.getError()); table.addRow("Pipeline URL", action.getLaunch().getPipeline()); table.addRow("Source", action.getSource().toString()); table.addRow("Hook URL", action.getHookUrl()); + addTriggerRows(table); table.addRow("Last event", FormatHelper.formatTime(action.getLastSeen())); table.addRow("Date created", FormatHelper.formatTime(action.getDateCreated())); table.addRow("Last event", FormatHelper.formatTime(action.getLastSeen())); @@ -76,4 +79,17 @@ public void toString(PrintWriter out) { } + private void addTriggerRows(TableList table) { + switch (action.getConfig()) { + case BucketActionConfig bucket -> { + table.addRow("Data link ID", bucket.getDataLinkId()); + table.addRow("Bucket", bucket.getBucketName()); + table.addRow("Marker file", bucket.getMarkerFile()); + table.addRow("Events", bucket.getEvents() == null ? null : String.join(", ", bucket.getEvents())); + } + case null, default -> { + } + } + } + } diff --git a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java index a86a8d111..fd185d0c0 100644 --- a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java @@ -34,10 +34,12 @@ import org.mockserver.client.MockServerClient; import org.mockserver.matchers.MatchType; import org.mockserver.model.MediaType; +import org.mockserver.verify.VerificationTimes; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.regex.Matcher; import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME; import static io.seqera.tower.cli.utils.JsonHelper.parseJson; @@ -792,6 +794,112 @@ void testPauseError(MockServerClient mock) throws IOException { assertEquals(errorMessage(out.app, new TowerException(String.format("An error has occur while setting the action '%s' to '%s'", "hello", "PAUSE"))), out.stdErr); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testAddBucket(OutputType format, MockServerClient mock) { + mock.reset(); + mockPrimaryComputeEnv(mock); + + mock.when( + request().withMethod("POST").withPath("/actions") + .withBody(json("{\"name\": \"on-upload\", \"source\": \"bucket\", \"bucket\": {\"dataLinkId\": \"v1-user-abc\", \"markerFile\": \"incoming/.done\", \"events\": [\"object:created\", \"object:deleted\"]}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("/actions/action_add")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "actions", "add", "bucket", "-n", "on-upload", "--pipeline", "https://github.com/pditommaso/nf-sleep", + "--data-link-id", "v1-user-abc", "--marker-file", "incoming/.done", "--events", "object:created,object:deleted"); + assertOutput(format, out, new ActionAdd("on-upload", USER_WORKSPACE_NAME, "2Z1g6MCWpOLgHLA65cw1qt")); + } + + @Test + void testUpdateBucket(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "bucket", BUCKET_CONFIG); + + mock.when( + request().withMethod("PUT").withPath("/actions/57byWxhmUDLLWIF4J97XEP") + .withBody(json("{\"name\": \"hello\", \"bucket\": {\"markerFile\": \"signals/*.complete\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--marker-file", "signals/*.complete"); + assertOutput(OutputType.console, out, new ActionUpdate("hello", USER_WORKSPACE_NAME, "57byWxhmUDLLWIF4J97XEP")); + } + + @Test + void testUpdateBucketOptionsOnGithubAction(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "github", "{\"events\": [\"push\"], \"discriminator\": \"github\"}"); + + ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--events", "object:created"); + + assertEquals(1, out.exitCode); + assertEquals(errorMessage(out.app, new TowerException("Options --marker-file and --events apply only to bucket actions, but action 'hello' is a github action")), out.stdErr); + mock.verify(request().withMethod("PUT"), VerificationTimes.never()); + } + + @Test + void testViewBucket(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "bucket", BUCKET_CONFIG); + mockUserInfo(mock); + + ExecOut out = exec(mock, "actions", "view", "-n", "hello"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.matches("(?s).*Data link ID +\\| v1-user-abc.*"), out.stdOut); + assertTrue(out.stdOut.matches("(?s).*Bucket +\\| my-bucket.*"), out.stdOut); + assertTrue(out.stdOut.matches("(?s).*Marker file +\\| incoming/\\.done.*"), out.stdOut); + assertTrue(out.stdOut.matches("(?s).*Events +\\| object:created, object:deleted.*"), out.stdOut); + } + + private static final String BUCKET_CONFIG = "{\"dataLinkId\": \"v1-user-abc\", \"bucketName\": \"my-bucket\", \"markerFile\": \"incoming/.done\", \"events\": [\"object:created\", \"object:deleted\"], \"discriminator\": \"bucket\"}"; + + private void mockPrimaryComputeEnv(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"message\":null,\"lastUsed\":null,\"primary\":true,\"workspaceName\":null,\"visibility\":null}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_env_demo")).withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockUserInfo(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + } + + /** Serves action 'hello' as an action of the given source and trigger configuration. */ + private void mockActionLookup(MockServerClient mock, String source, String configJson) { + mock.when( + request().withMethod("GET").withPath("/actions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("actions/actions_list")).withContentType(MediaType.APPLICATION_JSON) + ); + + String view = new String(loadResource("actions/action_view")) + .replace("\"source\": \"github\"", "\"source\": \"" + source + "\"") + .replaceFirst("(?m)^ \"config\": \\{[^}]*\\}", Matcher.quoteReplacement("\"config\": " + configJson)); + mock.when( + request().withMethod("GET").withPath("/actions/57byWxhmUDLLWIF4J97XEP"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(view).withContentType(MediaType.APPLICATION_JSON) + ); + } + @Test void testAddWithoutSubCommands(MockServerClient mock) { ExecOut out = exec(mock, "actions", "add"); From 1b193711fef35d789b111f6a497e8efb911f5784 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:00:24 +0200 Subject: [PATCH 02/10] fix(actions): list actions that have no endpoint Bucket actions (API 1.145) have a null hookUrl until their bucket notification is provisioned: Platform only sets it in ActionServiceImpl.persistBucketProvisioning. So the list API returns a null endpoint for a bucket action that is still being created or whose provisioning failed. ActionsList built each row with List.of, which rejects null, so 'tw actions list' failed with a NullPointerException in any workspace holding one. TableList already prints a null cell as empty. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/responses/actions/ActionsList.java | 4 +++- .../tower/cli/actions/ActionsCmdTest.java | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java index a556359d0..9efd7b581 100644 --- a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java +++ b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java @@ -23,6 +23,7 @@ import java.io.PrintWriter; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -68,7 +69,8 @@ public void toString(PrintWriter out) { actions.forEach(element -> { - List rows = new ArrayList<>(List.of( + // Arrays.asList, not List.of: bucket actions have no endpoint until provisioned. + List rows = new ArrayList<>(Arrays.asList( formatActionId(element.getId(), baseWorkspaceUrl), element.getName(), element.getEndpoint(), diff --git a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java index fd185d0c0..b40569890 100644 --- a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java @@ -860,6 +860,26 @@ void testViewBucket(MockServerClient mock) { private static final String BUCKET_CONFIG = "{\"dataLinkId\": \"v1-user-abc\", \"bucketName\": \"my-bucket\", \"markerFile\": \"incoming/.done\", \"events\": [\"object:created\", \"object:deleted\"], \"discriminator\": \"bucket\"}"; + @Test + void testListNewSources(MockServerClient mock) { + mock.reset(); + mockUserInfo(mock); + + mock.when( + request().withMethod("GET").withPath("/actions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"actions\": [" + + "{\"id\": \"2b\", \"name\": \"on-upload\", \"source\": \"bucket\", \"status\": \"ERROR\", \"endpoint\": null, \"config\": " + BUCKET_CONFIG + "}" + + "]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "actions", "list"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.matches("(?s).*on-upload .*bucket.*"), out.stdOut); + } + private void mockPrimaryComputeEnv(MockServerClient mock) { mock.when( request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) From 0967f8d7a514904f1690e781eece4642870b3607 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:38:43 +0200 Subject: [PATCH 03/10] fix(actions): show 'Last event' once in 'actions view' The row was printed twice, before and after 'Date created', since the view was first written (605e0114, 5568f536). Noticed while adding the per-source trigger rows to the same table. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/io/seqera/tower/cli/responses/actions/ActionsView.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java index 9dd51c2cf..8628dbb6c 100644 --- a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java +++ b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java @@ -70,7 +70,6 @@ public void toString(PrintWriter out) { addTriggerRows(table); table.addRow("Last event", FormatHelper.formatTime(action.getLastSeen())); table.addRow("Date created", FormatHelper.formatTime(action.getDateCreated())); - table.addRow("Last event", FormatHelper.formatTime(action.getLastSeen())); table.addRow("Labels", action.getLabels() == null || action.getLabels().isEmpty() ? "No labels found" : formatLabels(action.getLabels())); table.print(); From 10da73367f2e497edd397754e0aaf7260e526332 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:40:21 +0200 Subject: [PATCH 04/10] feat(studios): mount data-link folders DataStudioConfiguration.mountDataV2 (platform API 1.114.0, platform commit ac72147bb3; first in the published spec at 1.145.0) mounts a data link with an optional sub-path, and the platform gives it precedence over the deprecated mountData. The UI now sends only mountDataV2, and studio descriptions return both fields, so 'tw studios start|update|add-as-new' with a mount option sent a new mountData while the fetched mountDataV2 still held the old mounts: the platform kept the old mounts and silently ignored the change. tw now always sends mountDataV2 (plus mountData for older platforms) when a mount option is given. '--mount-data-uris' also accepts a URI inside a data link: tw resolves the closest parent data link and mounts the remainder as the path, which is what the UI's folder picker does. 'studios view' prints the mounted path. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../commands/data/links/DataLinkService.java | 58 ++++++++---- .../commands/studios/AbstractStudiosCmd.java | 15 ++- .../commands/studios/DataLinkRefOptions.java | 2 +- .../cli/responses/studios/StudiosView.java | 26 ++++- .../tower/cli/studios/StudiosCmdTest.java | 94 +++++++++++++++++++ 5 files changed, 170 insertions(+), 25 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/data/links/DataLinkService.java b/src/main/java/io/seqera/tower/cli/commands/data/links/DataLinkService.java index 9a9f277eb..ae0b405ad 100644 --- a/src/main/java/io/seqera/tower/cli/commands/data/links/DataLinkService.java +++ b/src/main/java/io/seqera/tower/cli/commands/data/links/DataLinkService.java @@ -16,8 +16,8 @@ package io.seqera.tower.cli.commands.data.links; -import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -30,6 +30,7 @@ import io.seqera.tower.cli.exceptions.TowerRuntimeException; import io.seqera.tower.cli.utils.ResponseHelper; import io.seqera.tower.model.DataLinkDto; +import io.seqera.tower.model.MountData; public class DataLinkService { @@ -114,10 +115,10 @@ public DataLinkDto getDataLink(DataLinkRefOptions.DataLinkRef dataLinkRef, Long return null; } - public List getDataLinkIds(io.seqera.tower.cli.commands.studios.DataLinkRefOptions.DataLinkRef dataLinkRef, Long wspId) { + public List getMountData(io.seqera.tower.cli.commands.studios.DataLinkRefOptions.DataLinkRef dataLinkRef, Long wspId) { // if DataLink IDs are supplied - use those directly if (dataLinkRef.getMountDataIds() != null) { - return dataLinkRef.getMountDataIds(); + return dataLinkRef.getMountDataIds().stream().map(id -> new MountData().dataLinkId(id)).collect(Collectors.toList()); } // Check and wait if DataLinks are still being fetched @@ -126,21 +127,37 @@ public List getDataLinkIds(io.seqera.tower.cli.commands.studios.DataLink throw new TowerRuntimeException("Failed to fetch datalinks for mountData - please retry."); } - List dataLinkIds = new ArrayList<>(); - if (dataLinkRef.getMountDataNames() != null) { - dataLinkIds = dataLinkRef.getMountDataNames().stream() - .map(name -> getDataLinkByName(wspId, null, name).getId()) + return dataLinkRef.getMountDataNames().stream() + .map(name -> new MountData().dataLinkId(getDataLinkByName(wspId, null, name).getId())) .collect(Collectors.toList()); } - if (dataLinkRef.getMountDataUris() != null) { - dataLinkIds = dataLinkRef.getMountDataUris().stream() - .map(resourceRef -> getDataLinkByResourceRef(wspId, null, resourceRef).getId()) - .collect(Collectors.toList()); - } + return dataLinkRef.getMountDataUris().stream() + .map(uri -> getMountDataByUri(wspId, uri)) + .collect(Collectors.toList()); + } - return dataLinkIds; + /** + * Resolves a URI to the data link whose resource reference is the URI itself or its closest parent, + * so that a folder inside a data link can be mounted (the remainder becomes the mount path). + */ + private MountData getMountDataByUri(Long wspId, String uri) { + String target = uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri; + int rootEnd = target.indexOf("://") + 2; + String candidate = target; + while (true) { + Optional dataLink = findDataLinkByResourceRef(wspId, candidate); + if (dataLink.isPresent()) { + MountData mountData = new MountData().dataLinkId(dataLink.get().getId()); + return candidate.equals(target) ? mountData : mountData.path(target.substring(candidate.length() + 1)); + } + int parentEnd = candidate.lastIndexOf('/'); + if (parentEnd <= rootEnd) { + throw new DataLinkNotFoundException(getResourceRefKeywordParam(uri), wspId); + } + candidate = candidate.substring(0, parentEnd); + } } private DataLinkDto getDataLinkByName(Long wspId, String credId, String name) { @@ -151,21 +168,26 @@ private DataLinkDto getDataLinkByResourceRef(Long wspId, String credId, String r return getDataLinkBySearchAndFindExactMatch(wspId, getResourceRefKeywordParam(resourceRef), credId, datalink -> resourceRef.equals(datalink.getResourceRef())); } + private Optional findDataLinkByResourceRef(Long wspId, String resourceRef) { + return findDataLinkBySearchAndExactMatch(wspId, getResourceRefKeywordParam(resourceRef), null, datalink -> resourceRef.equals(datalink.getResourceRef())); + } + private DataLinkDto getDataLinkBySearchAndFindExactMatch(Long wspId, String search, String credId, Predicate filter) { + return findDataLinkBySearchAndExactMatch(wspId, search, credId, filter) + .orElseThrow(() -> new DataLinkNotFoundException(search, wspId)); + } + + private Optional findDataLinkBySearchAndExactMatch(Long wspId, String search, String credId, Predicate filter) { var datalinks = getDataLinksBySearchCriteria(wspId, search, credId).stream() .filter(filter) .collect(Collectors.toList()); - if (datalinks.isEmpty()) { - throw new DataLinkNotFoundException(search, wspId); - } - if (datalinks.size() > 1) { var dataLinkIds = datalinks.stream().map(DataLinkDto::getId).collect(Collectors.toList()); throw new MultipleDataLinksFoundException(search, wspId, dataLinkIds); } - return datalinks.get(0); + return datalinks.stream().findFirst(); } private DataLinkDto getDataLinkById(String dataLinkId, Long wspId, String credId) { diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java index 093eca991..55daba69e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java @@ -40,6 +40,7 @@ import io.seqera.tower.model.DataStudioTemplatesListResponse; import io.seqera.tower.model.ListMembersResponse; import io.seqera.tower.model.MemberDbDto; +import io.seqera.tower.model.MountData; import static io.seqera.tower.cli.utils.ResponseHelper.waitStatus; import static io.seqera.tower.model.DataStudioProgressStepStatus.ERRORED; @@ -184,7 +185,7 @@ protected DataStudioConfiguration studioConfigurationFrom(Long wspId, DataStudio ? studioConfiguration.getLifespanHours() : configOptions.lifespan); - studioConfiguration.setMountData(getMountDataIds(configOptions, studioConfiguration, wspId)); + applyMountData(configOptions, studioConfiguration, wspId); if (configOptions.environment != null) { studioConfiguration.setEnvironment(configOptions.environment); @@ -198,13 +199,19 @@ protected DataStudioConfiguration studioConfigurationFrom(Long wspId, DataStudio return studioConfiguration; } - List getMountDataIds(StudioConfigurationOptions studioConfigOptions, DataStudioConfiguration currentStudioConfiguration, Long wspId) throws ApiException { + private void applyMountData(StudioConfigurationOptions studioConfigOptions, DataStudioConfiguration studioConfiguration, Long wspId) throws ApiException { if (studioConfigOptions.dataLinkRefOptions == null || studioConfigOptions.dataLinkRefOptions.dataLinkRef == null) { - return currentStudioConfiguration.getMountData(); + return; } DataLinkService dataLinkService = new DataLinkService(dataLinksApi(), app()); - return dataLinkService.getDataLinkIds(studioConfigOptions.dataLinkRefOptions.dataLinkRef, wspId); + List mountData = dataLinkService.getMountData(studioConfigOptions.dataLinkRefOptions.dataLinkRef, wspId); + + // The platform gives mountDataV2 precedence, so it must be replaced too: a base configuration + // fetched from an existing studio carries its current mounts there. + studioConfiguration.setMountDataV2(mountData); + // Kept for platform versions that predate mountDataV2; they mount the whole data link. + studioConfiguration.setMountData(mountData.stream().map(MountData::getDataLinkId).distinct().toList()); } protected List getLabelIds(List