From 70346a12597074a022daf65f12b0d7f023f6ae49 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:46:16 +0200 Subject: [PATCH 1/7] 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 | 93 +++++++++++++++++++ 5 files changed, 169 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 9a32b8100..233e37400 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