diff --git a/USAGE.md b/USAGE.md index bc8bce04e..84e08f161 100644 --- a/USAGE.md +++ b/USAGE.md @@ -713,10 +713,19 @@ $ tw data-links browse -w seqeralabs/showcase -i v1-user-6d8f44c239e2a098b3e02e9 These commands act on an organization (`-o`/`--organization`). Most require the organization `OWNER` role. +### Custom roles + +Run `tw roles -h` to view the supported operations: `list`, `view`, `add`, `update`, `delete`, and `permissions`. `tw participants update --role` accepts custom role names. + +```bash +tw roles permissions -o my-org +tw roles add -o my-org -n launcher -d "Launch only" -p , +``` + ### Teams and quotas - `tw teams view|update|workspaces`: view a team, update its name or description, and list its workspaces. -- `tw organizations quotas`: view organization quotas. +- `tw organizations quotas` and `tw members roles -u `: view organization quotas and a member's workspace roles. ## Personal access @@ -756,9 +765,9 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s - `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. -- `tw studios extend|rename-checkpoint`. +- `tw studios extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. - `tw data-links delete-content|download-script`. -- `tw datasets disable-version`. +- `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`. [compute-envs]: https://docs.seqera.io/platform/latest/compute-envs/overview [credentials]: https://docs.seqera.io/platform/latest/credentials/overview diff --git a/conf/reflect-config.json b/conf/reflect-config.json index c859a2a9d..a162cd754 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -250,6 +250,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.RolesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.RunsCmd", "allDeclaredFields":true, @@ -1614,6 +1620,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.members.RolesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"user"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.members.UpdateCmd", "allDeclaredFields":true, @@ -1859,6 +1872,48 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.roles.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"name"}, {"name":"organizationRef"}, {"name":"permissions"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.roles.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"name"}, {"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.roles.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filter"}, {"name":"organizationRef"}, {"name":"paginationOptions"}, {"name":"type"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.roles.PermissionsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.roles.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"name"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"permissions"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.roles.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"name"}, {"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.AbstractRunsCmd", "allDeclaredFields":true, @@ -2741,6 +2796,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.members.MemberRolesList", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.members.MembersAdded", "allDeclaredFields":true, @@ -2907,6 +2970,42 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.roles.RoleAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.roles.RoleDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.roles.RolePermissionsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.roles.RoleUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.roles.RoleView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.roles.RolesList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.RunCanceled", "allDeclaredFields":true, diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index fb37fca48..68c8c9e70 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -37,6 +37,7 @@ import io.seqera.tower.cli.commands.ParticipantsCmd; import io.seqera.tower.cli.commands.PipelinesCmd; import io.seqera.tower.cli.commands.PipelineSchemasCmd; +import io.seqera.tower.cli.commands.RolesCmd; import io.seqera.tower.cli.commands.RunsCmd; import io.seqera.tower.cli.commands.SecretsCmd; import io.seqera.tower.cli.commands.SshKeysCmd; @@ -77,6 +78,7 @@ ParticipantsCmd.class, PipelinesCmd.class, PipelineSchemasCmd.class, + RolesCmd.class, RunsCmd.class, SshKeysCmd.class, TeamsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 842b619b9..5f1221e41 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -36,6 +36,7 @@ import io.seqera.tower.api.PipelineVersionsApi; import io.seqera.tower.api.PipelinesApi; import io.seqera.tower.api.PlatformsApi; +import io.seqera.tower.api.RolesApi; import io.seqera.tower.api.ServiceInfoApi; import io.seqera.tower.api.SshKeysApi; import io.seqera.tower.api.StudiosApi; @@ -136,6 +137,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private PipelineSecretsApi pipelineSecretsApi; private PipelineVersionsApi pipelineVersionsApi; private PlatformsApi platformsApi; + private RolesApi rolesApi; private ServiceInfoApi serviceInfoApi; private SshKeysApi sshKeysApi; private StudiosApi studiosApi; @@ -267,6 +269,10 @@ protected PlatformsApi platformsApi() throws ApiException { return platformsApi == null ? new PlatformsApi(apiClient()) : platformsApi; } + protected RolesApi rolesApi() throws ApiException { + return rolesApi == null ? new RolesApi(apiClient()) : rolesApi; + } + protected ServiceInfoApi serviceInfoApi() throws ApiException { return serviceInfoApi == null ? new ServiceInfoApi(apiClient()) : serviceInfoApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/MembersCmd.java b/src/main/java/io/seqera/tower/cli/commands/MembersCmd.java index 2efc77e7d..d204c239b 100644 --- a/src/main/java/io/seqera/tower/cli/commands/MembersCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/MembersCmd.java @@ -20,6 +20,7 @@ import io.seqera.tower.cli.commands.members.DeleteCmd; import io.seqera.tower.cli.commands.members.LeaveCmd; import io.seqera.tower.cli.commands.members.ListCmd; +import io.seqera.tower.cli.commands.members.RolesCmd; import io.seqera.tower.cli.commands.members.UpdateCmd; import picocli.CommandLine; @@ -32,6 +33,7 @@ DeleteCmd.class, UpdateCmd.class, LeaveCmd.class, + RolesCmd.class, } ) public class MembersCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/RolesCmd.java b/src/main/java/io/seqera/tower/cli/commands/RolesCmd.java new file mode 100644 index 000000000..74db22e56 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/RolesCmd.java @@ -0,0 +1,40 @@ +/* + * 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; + +import io.seqera.tower.cli.commands.roles.AddCmd; +import io.seqera.tower.cli.commands.roles.DeleteCmd; +import io.seqera.tower.cli.commands.roles.ListCmd; +import io.seqera.tower.cli.commands.roles.PermissionsCmd; +import io.seqera.tower.cli.commands.roles.UpdateCmd; +import io.seqera.tower.cli.commands.roles.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "roles", + description = "Manage workspace roles. Custom roles are not available for Seqera Cloud Basic organizations.", + subcommands = { + ListCmd.class, + ViewCmd.class, + AddCmd.class, + UpdateCmd.class, + DeleteCmd.class, + PermissionsCmd.class, + } +) +public class RolesCmd extends AbstractRootCmd { +} 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..7093bcb49 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,41 @@ 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()); + } + + /** + * 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 = stripTrailingSlash(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); } + } - return dataLinkIds; + private static String stripTrailingSlash(String ref) { + return ref != null && ref.endsWith("/") ? ref.substring(0, ref.length() - 1) : ref; } private DataLinkDto getDataLinkByName(Long wspId, String credId, String name) { @@ -151,21 +172,27 @@ 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) { + // Data links created before Platform normalised resource refs may still be stored with a trailing slash + return findDataLinkBySearchAndExactMatch(wspId, getResourceRefKeywordParam(resourceRef), null, datalink -> resourceRef.equals(stripTrailingSlash(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/datasets/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/datasets/AddCmd.java index fa1729801..41a976c8c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/datasets/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/datasets/AddCmd.java @@ -24,6 +24,10 @@ import io.seqera.tower.cli.responses.datasets.DatasetCreate; import io.seqera.tower.model.CreateDatasetRequest; import io.seqera.tower.model.CreateDatasetResponse; +import io.seqera.tower.model.LinkVersionRequest; +import io.seqera.tower.model.SourceType; +import io.seqera.tower.model.ValidateUrlRequest; +import io.seqera.tower.model.ValidateUrlResponse; import picocli.CommandLine; import java.io.File; @@ -45,9 +49,12 @@ public class AddCmd extends AbstractDatasetsCmd { @CommandLine.Option(names = {"--header"}, description = "Treat first row as header. Default: false.") public boolean header = false; - @CommandLine.Parameters(index = "0", paramLabel = "FILENAME", description = "Data file to upload", arity = "1") + @CommandLine.Parameters(index = "0", paramLabel = "FILENAME", description = "Data file to upload. Not allowed with --url.", arity = "0..1") Path fileName = null; + @CommandLine.Option(names = {"--url"}, description = "Public HTTP or HTTPS URL of a CSV or TSV file to link instead of uploading a file. Runs read the file from the URL.") + public String url; + @CommandLine.Mixin public WorkspaceRequiredOptions workspace; @@ -56,27 +63,48 @@ public class AddCmd extends AbstractDatasetsCmd { @Override protected Response exec() throws ApiException, IOException { - File dataset = fileName.toFile(); - if (!dataset.exists()) { - throw new TowerException(String.format("File path '%s' do not exists.", fileName)); + if ((fileName == null) == (url == null)) { + throw new TowerException("Provide either a FILENAME to upload or a --url to link"); } - - if (dataset.isDirectory()) { - throw new TowerException(String.format("File path '%s' must be a file, not a directory.", fileName)); + if (fileName != null) { + File dataset = fileName.toFile(); + if (!dataset.exists()) { + throw new TowerException(String.format("File path '%s' do not exists.", fileName)); + } + + if (dataset.isDirectory()) { + throw new TowerException(String.format("File path '%s' must be a file, not a directory.", fileName)); + } } Long wspId = workspaceId(workspace.workspace); + // Like the UI, check the URL before creating the dataset so that an unusable URL leaves no empty dataset behind. + if (url != null) { + ValidateUrlResponse validation = datasetsApi().validateDatasetUrl(new ValidateUrlRequest().url(url), wspId); + if (!Boolean.TRUE.equals(validation.getValid())) { + throw new TowerException(String.format("Dataset URL '%s' cannot be linked: %s", url, validation.getErrorMessage())); + } + } + CreateDatasetRequest request = new CreateDatasetRequest(); request.setName(name); request.setDescription(description); + if (url != null) { + request.setSourceType(SourceType.LINKED); + } if (overwrite) tryDeleteDataset(name, wspId); CreateDatasetResponse response = datasetsApi().createDatasetV2(request, wspId); + String datasetId = response.getDataset().getId(); - datasetsApi().uploadDatasetV2(response.getDataset().getId(), wspId, header, fileName.toFile()); + if (url != null) { + datasetsApi().linkDatasetVersion(datasetId, new LinkVersionRequest().url(url).hasHeader(header), wspId); + } else { + datasetsApi().uploadDatasetV2(datasetId, wspId, header, fileName.toFile()); + } - return new DatasetCreate(response.getDataset().getName(), workspace.workspace, response.getDataset().getId()); + return new DatasetCreate(response.getDataset().getName(), workspace.workspace, datasetId); } private void tryDeleteDataset(String datasetName, Long wspId) throws ApiException { diff --git a/src/main/java/io/seqera/tower/cli/commands/datasets/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/datasets/UpdateCmd.java index 60a3bef84..03bedeabb 100644 --- a/src/main/java/io/seqera/tower/cli/commands/datasets/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/datasets/UpdateCmd.java @@ -22,6 +22,7 @@ import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.datasets.DatasetUpdate; import io.seqera.tower.model.DatasetDto; +import io.seqera.tower.model.LinkVersionRequest; import io.seqera.tower.model.UpdateDatasetRequest; import picocli.CommandLine; @@ -46,15 +47,21 @@ public class UpdateCmd extends AbstractDatasetsCmd { @CommandLine.Option(names = {"--header"}, description = "Treat first row as header") public boolean header = false; - @CommandLine.Option(names = {"-f", "--file"}, description = "Data file to upload") + @CommandLine.Option(names = {"-f", "--file"}, description = "Data file to upload as a new version") Path fileName = null; + @CommandLine.Option(names = {"--url"}, description = "Public HTTP or HTTPS URL of a CSV or TSV file to link as a new version. Only for datasets created with --url.") + public String url; + @CommandLine.Mixin public WorkspaceRequiredOptions workspace; @Override protected Response exec() throws ApiException, IOException { - if (fileName == null && header) { + if (fileName != null && url != null) { + throw new TowerException("Provide either a --file to upload or a --url to link, not both"); + } + if (fileName == null && url == null && header) { throw new TowerException("Please provide a new file version to upload or remove the --header option"); } @@ -69,6 +76,9 @@ protected Response exec() throws ApiException, IOException { if (fileName != null) { datasetsApi().uploadDatasetV2(dataset.getId(), wspId, header, fileName.toFile()); } + if (url != null) { + datasetsApi().linkDatasetVersion(dataset.getId(), new LinkVersionRequest().url(url).hasHeader(header), wspId); + } return new DatasetUpdate(dataset.getName(), workspace.workspace, dataset.getId()); } diff --git a/src/main/java/io/seqera/tower/cli/commands/members/RolesCmd.java b/src/main/java/io/seqera/tower/cli/commands/members/RolesCmd.java new file mode 100644 index 000000000..60f0e97f8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/members/RolesCmd.java @@ -0,0 +1,44 @@ +/* + * 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.members; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.members.MemberRolesList; +import io.seqera.tower.model.MemberDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "roles", + description = "List the workspace roles of an organization member, granted directly or through teams" +) +public class RolesCmd extends AbstractMembersClass { + + @Option(names = {"-u", "--user"}, description = "Username or email address of the organization member.", required = true) + public String user; + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + MemberDbDto member = findMemberByUser(orgId, user); + return new MemberRolesList(organizationRef, orgsApi().listUserRolesInOrganization(orgId, member.getUserId())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/roles/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/roles/AddCmd.java new file mode 100644 index 000000000..cf211d057 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/roles/AddCmd.java @@ -0,0 +1,60 @@ +/* + * 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.roles; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.roles.RoleAdded; +import io.seqera.tower.model.CreateRoleRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.util.List; + +@Command( + name = "add", + description = "Add a custom role" +) +public class AddCmd extends AbstractApiCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Custom role name. Must be unique within the organization and different from the predefined role names.", required = true) + public String name; + + @Option(names = {"-d", "--description"}, description = "Custom role description (max 120 characters).", required = true) + public String description; + + @Option(names = {"-p", "--permissions"}, split = ",", description = "Comma-separated list of permissions granted by the role. See 'tw roles permissions' for the available names.", required = true) + public List permissions; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + + CreateRoleRequest request = new CreateRoleRequest() + .name(name) + .description(description) + .permissions(permissions); + + rolesApi().createRole(request, orgId); + + return new RoleAdded(organizationRef, name); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/roles/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/roles/DeleteCmd.java new file mode 100644 index 000000000..70d10aca9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/roles/DeleteCmd.java @@ -0,0 +1,44 @@ +/* + * 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.roles; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.roles.RoleDeleted; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a custom role" +) +public class DeleteCmd extends AbstractApiCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Custom role name.", required = true) + public String name; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + rolesApi().deleteRole(name, orgId); + return new RoleDeleted(organizationRef, name); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/roles/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/roles/ListCmd.java new file mode 100644 index 000000000..3d95e2277 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/roles/ListCmd.java @@ -0,0 +1,59 @@ +/* + * 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.roles; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.roles.RolesList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListRolesResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List predefined and custom roles" +) +public class ListCmd extends AbstractApiCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Option(names = {"-t", "--type"}, description = "Role type to list (predefined or custom).") + public String type; + + @Option(names = {"-f", "--filter"}, description = "Show only roles whose name contains the given text (* wildcards allowed).") + public String filter; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ListRolesResponse response = rolesApi().listRoles(orgId, max, offset, filter, type); + + Long total = response.getTotalSize() == null ? null : response.getTotalSize().longValue(); + return new RolesList(organizationRef, response.getRoles(), PaginationInfo.from(paginationOptions, total)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/roles/PermissionsCmd.java b/src/main/java/io/seqera/tower/cli/commands/roles/PermissionsCmd.java new file mode 100644 index 000000000..a73b4ba8a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/roles/PermissionsCmd.java @@ -0,0 +1,41 @@ +/* + * 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.roles; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.roles.RolePermissionsList; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "permissions", + description = "List the permissions that can be granted by a custom role" +) +public class PermissionsCmd extends AbstractApiCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + // The name filter is typed as int64 in the API spec, so it can't carry a search text + return new RolePermissionsList(rolesApi().listRolePermissions(orgId, null).getPermissions()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/roles/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/roles/UpdateCmd.java new file mode 100644 index 000000000..0832918f8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/roles/UpdateCmd.java @@ -0,0 +1,66 @@ +/* + * 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.roles; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.roles.RoleUpdated; +import io.seqera.tower.model.RoleDto; +import io.seqera.tower.model.UpdateRoleRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.util.List; + +@Command( + name = "update", + description = "Update a custom role" +) +public class UpdateCmd extends AbstractApiCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Custom role name.", required = true) + public String name; + + @Option(names = {"--new-name"}, description = "New custom role name.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "New custom role description (max 120 characters).") + public String description; + + @Option(names = {"-p", "--permissions"}, split = ",", description = "Comma-separated list of permissions. Replaces the current permissions of the role.") + public List permissions; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + RoleDto role = rolesApi().describeRole(name, orgId).getRole(); + + // The API replaces name, description and permissions, so unset options keep the current values + UpdateRoleRequest request = new UpdateRoleRequest() + .name(newName != null ? newName : role.getName()) + .description(description != null ? description : role.getDescription()) + .permissions(permissions != null ? permissions : role.getPermissions()); + + rolesApi().updateRole(name, request, orgId); + + return new RoleUpdated(organizationRef, request.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/roles/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/roles/ViewCmd.java new file mode 100644 index 000000000..3f8a7eac1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/roles/ViewCmd.java @@ -0,0 +1,43 @@ +/* + * 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.roles; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.roles.RoleView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View a role and its permissions" +) +public class ViewCmd extends AbstractApiCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Role name.", required = true) + public String name; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new RoleView(organizationRef, rolesApi().describeRole(name, orgId).getRole()); + } +} 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