diff --git a/USAGE.md b/USAGE.md index 84e08f16..c7ac488a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -762,10 +762,10 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s - AWS Cloud: `--log-group`. Google Cloud: `--project-id`. - HPC platforms: `--propagate-head-job-options`. - Seqera Compute: `--data-retention-policy`. -- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. +- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. List the Nextflow and Fusion versions you can launch with `tw info versions` (`-c fusion` for Fusion). - `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`. `--mount-data-uris` also accepts a folder inside a data link. +- `tw studios star|unstar|extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. - `tw data-links delete-content|download-script`. - `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`. diff --git a/conf/reflect-config.json b/conf/reflect-config.json index a162cd75..2cc75b0b 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1431,6 +1431,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.info.VersionsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"component"}, {"name":"fusion"}, {"name":"nextflow"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.labels.AbstractLabelsCmd", "allDeclaredFields":true, @@ -2183,6 +2190,13 @@ "fields":[{"name":"checkpointId"}, {"name":"newName"}, {"name":"studioRefOptions"}, {"name":"workspace"}], "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.StarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StartCmd", "allDeclaredFields":true, @@ -2250,6 +2264,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.UnstarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.UpdateCmd", "allDeclaredFields":true, @@ -2426,6 +2447,19 @@ "allDeclaredConstructors":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"setMessage","parameterTypes":["java.lang.String"] }] }, +{ + "name":"io.seqera.tower.cli.responses.ComponentVersions", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.ComponentVersions$ComponentVersion", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"isDefault","parameterTypes":[] }, {"name":"version","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.responses.CredentialsAdded", "allDeclaredFields":true, @@ -3176,6 +3210,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioStarred", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStartSubmitted", "allDeclaredFields":true, 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 5f1221e4..41de2bb1 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -22,6 +22,7 @@ import io.seqera.tower.Pair; import io.seqera.tower.api.ActionsApi; import io.seqera.tower.api.AvatarsApi; +import io.seqera.tower.api.CompatibilityApi; import io.seqera.tower.api.ComputeEnvsApi; import io.seqera.tower.api.CredentialsApi; import io.seqera.tower.api.DataLinksApi; @@ -30,6 +31,7 @@ import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; +import io.seqera.tower.api.NextflowApi; import io.seqera.tower.api.OrgsApi; import io.seqera.tower.api.PipelineSchemasApi; import io.seqera.tower.api.PipelineSecretsApi; @@ -123,6 +125,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private ActionsApi actionsApi; private AvatarsApi avatarsApi; + private CompatibilityApi compatibilityApi; private ComputeEnvsApi computeEnvsApi; private CredentialsApi credentialsApi; private DataLinksApi dataLinksApi; @@ -131,6 +134,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private IdentitiesApi identitiesApi; private LabelsApi labelsApi; private LaunchApi launchApi; + private NextflowApi nextflowApi; private OrgsApi orgsApi; private PipelinesApi pipelinesApi; private PipelineSchemasApi pipelineSchemasApi; @@ -213,6 +217,10 @@ protected AvatarsApi avatarsApi() throws ApiException { return avatarsApi == null ? new AvatarsApi(apiClient()) : avatarsApi; } + protected CompatibilityApi compatibilityApi() throws ApiException { + return compatibilityApi == null ? new CompatibilityApi(apiClient()) : compatibilityApi; + } + protected ComputeEnvsApi computeEnvsApi() throws ApiException { return computeEnvsApi == null ? new ComputeEnvsApi(apiClient()) : computeEnvsApi; } @@ -245,6 +253,10 @@ protected LaunchApi launchApi() throws ApiException { return launchApi == null ? new LaunchApi(apiClient()) : launchApi; } + protected NextflowApi nextflowApi() throws ApiException { + return nextflowApi == null ? new NextflowApi(apiClient()) : nextflowApi; + } + protected OrgsApi orgsApi() throws ApiException { return orgsApi == null ? new OrgsApi(apiClient()) : orgsApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java b/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java index 5b5ab3bb..fece7985 100644 --- a/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java @@ -17,6 +17,7 @@ package io.seqera.tower.cli.commands; import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.info.VersionsCmd; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.InfoResponse; import io.seqera.tower.cli.responses.Response; @@ -32,7 +33,10 @@ @CommandLine.Command( name = "info", - description = "Show system info and health status" + description = "Show system info and health status", + subcommands = { + VersionsCmd.class, + } ) public class InfoCmd extends AbstractRootCmd { @Override diff --git a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java index 345d7d48..8cbb28b0 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -21,6 +21,8 @@ import io.seqera.tower.cli.commands.studios.DeleteCmd; import io.seqera.tower.cli.commands.studios.ExtendCmd; import io.seqera.tower.cli.commands.studios.RenameCheckpointCmd; +import io.seqera.tower.cli.commands.studios.StarCmd; +import io.seqera.tower.cli.commands.studios.UnstarCmd; import io.seqera.tower.cli.commands.studios.ListCmd; import io.seqera.tower.cli.commands.studios.AddAsNewCmd; import io.seqera.tower.cli.commands.studios.StartCmd; @@ -45,6 +47,8 @@ StopCmd.class, UpdateCmd.class, DeleteCmd.class, + StarCmd.class, + UnstarCmd.class, ExtendCmd.class, } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java new file mode 100644 index 00000000..0b1f374c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java @@ -0,0 +1,76 @@ +/* + * 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.info; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.ComponentVersions; +import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.CatalogVersion; +import io.seqera.tower.model.CatalogVersionsResponse; +import io.seqera.tower.model.NextflowVersion; +import io.seqera.tower.model.NextflowVersionsResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.util.List; +import java.util.Objects; + +@Command( + name = "versions", + description = "List the Nextflow or Fusion versions available to launch with" +) +public class VersionsCmd extends AbstractApiCmd { + + private static final String NEXTFLOW = "nextflow"; + + @Option(names = {"-c", "--component"}, description = "Component to list versions of: 'nextflow' or 'fusion'. Default: nextflow.", defaultValue = NEXTFLOW) + public String component; + + @Option(names = {"--nextflow"}, description = "Only list versions compatible with these Nextflow versions. Comma-separated list.") + public String nextflow; + + @Option(names = {"--fusion"}, description = "Only list versions compatible with these Fusion versions. Comma-separated list.") + public String fusion; + + @Override + protected Response exec() throws ApiException { + NextflowVersionsResponse platformVersions = nextflowApi().nextflowVersions(); + + if (!Boolean.TRUE.equals(platformVersions.getCatalogEnabled())) { + // Without the compatibility catalog, Platform only knows its configured Nextflow versions. + if (!NEXTFLOW.equals(component) || nextflow != null || fusion != null) { + throw new TowerException("The component compatibility catalog is not enabled on this Platform: only Nextflow versions can be listed, without compatibility filters"); + } + List versions = Objects.requireNonNullElse(platformVersions.getNextflowVersions(), List.of()).stream() + .map(v -> new ComponentVersion(v.getVersion(), Boolean.TRUE.equals(v.getDefault()))) + .toList(); + return new ComponentVersions(component, versions); + } + + // Constrain the catalog to this Platform release, as the launch form does. + String platform = serviceInfoApi().info().getServiceInfo().getVersion(); + CatalogVersionsResponse catalog = compatibilityApi().catalogComponentVersions(component, platform, nextflow, fusion, null, null); + List versions = Objects.requireNonNullElse(catalog.getVersions(), List.of()).stream() + .map(v -> new ComponentVersion(v.getVersion(), v.getVersion().equals(catalog.getDefaultVersion()))) + .toList(); + return new ComponentVersions(component, versions); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java index 8d114c9d..dc2c78c2 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java @@ -40,7 +40,7 @@ public class NullableLaunchOptions { @Option(names = {"--syntax-parser"}, description = "Nextflow language syntax parser version: 'v1' (legacy) or 'v2'. Takes precedence over the value stored in the launch configuration.") public SyntaxParserEnum syntaxParser; - @Option(names = {"--nextflow-version"}, description = "Nextflow version to run the workflow with. Must exist in the Platform version catalog and meet the minimum required by the compute environment. Takes precedence over the value stored in the launch configuration.") + @Option(names = {"--nextflow-version"}, description = "Nextflow version to run the workflow with. Must exist in the Platform version catalog and meet the minimum required by the compute environment (see 'tw info versions'). Takes precedence over the value stored in the launch configuration.") public String nextflowVersion; @Option(names = {"--output-dir"}, description = "Per-run output directory, passed to Nextflow as '-output-dir'. Requires Nextflow 24.10.0 or later and the workflow outputs syntax. Takes precedence over the value stored in the launch configuration.") diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java new file mode 100644 index 00000000..079871b5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java @@ -0,0 +1,46 @@ +/* + * 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.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "star", + description = "Add a studio to your starred studios." +) +public class StarCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + studiosApi().createDataStudioStar(sessionId, wspId); + + return new StudioStarred(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), true); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java new file mode 100644 index 00000000..b47d2dc0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java @@ -0,0 +1,46 @@ +/* + * 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.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unstar", + description = "Remove a studio from your starred studios." +) +public class UnstarCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + studiosApi().deleteDataStudioStar(sessionId, wspId); + + return new StudioStarred(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), false); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java b/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java new file mode 100644 index 00000000..4c27c8af --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java @@ -0,0 +1,52 @@ +/* + * 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.responses; + +import io.seqera.tower.cli.utils.TableList; + +import java.io.PrintWriter; +import java.util.List; + +public class ComponentVersions extends Response { + + public record ComponentVersion(String version, boolean isDefault) { + } + + public final String component; + public final List versions; + + public ComponentVersions(String component, List versions) { + this.component = component; + this.versions = versions; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Available %s versions:|@%n", component))); + if (versions.isEmpty()) { + out.println(ansi(String.format(" @|yellow No %s versions found|@", component))); + return; + } + + TableList table = new TableList(out, 2, "Version", "Default"); + table.setPrefix(" "); + versions.forEach(v -> table.addRow(v.version(), v.isDefault() ? "yes" : "no")); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java new file mode 100644 index 00000000..2a8ed4e2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.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.responses.studios; + +import io.seqera.tower.cli.responses.Response; + +public class StudioStarred extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final boolean starred; + + public StudioStarred(String userSuppliedStudioIdentifier, String workspaceRef, boolean starred) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.starred = starred; + } + + @Override + public String toString() { + if (starred) { + return ansi(String.format("%n @|yellow Studio %s starred at %s workspace.|@%n", userSuppliedStudioIdentifier, workspaceRef)); + } + return ansi(String.format("%n @|yellow Studio %s unstarred at %s workspace.|@%n", userSuppliedStudioIdentifier, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java index a74ff62f..3af4b146 100644 --- a/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java @@ -74,6 +74,7 @@ public void toString(PrintWriter out){ table.addRow("Memory allocated", config == null ? "-" : String.valueOf(config.getMemory())); table.addRow("Build reports", studio.getWaveBuildUrl() == null ? "NA" : studio.getWaveBuildUrl()); table.addRow("Private", studio.getIsPrivate() == null ? "NA" : String.valueOf(studio.getIsPrivate())); + table.addRow("Starred", studio.getStarred() == null ? "NA" : String.valueOf(studio.getStarred())); table.addRow("Lifespan (hours)", studio.getEffectiveLifespanHours() == null ? "Unlimited" : String.valueOf(studio.getEffectiveLifespanHours())); table.addRow("SSH enabled", config == null || config.getSshEnabled() == null ? "false" : String.valueOf(config.getSshEnabled())); diff --git a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java index 40f02332..93c699ff 100644 --- a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java @@ -17,6 +17,9 @@ package io.seqera.tower.cli; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.ComponentVersions; +import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; import io.seqera.tower.cli.responses.InfoResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,11 +29,13 @@ import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import static org.apache.commons.lang3.StringUtils.chop; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; @@ -156,6 +161,89 @@ void testInfoStatusUrlFail(MockServerClient mock) throws IOException { assertEquals(chop(new InfoResponse(0,-1,-1, opts).toString()), out.stdOut); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testVersionsFromCatalog(OutputType format, MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"catalogEnabled\":true,\"nextflowVersions\":[]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/service-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("info/service-info")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/catalog/components/fusion/versions") + .withQueryStringParameter("platform", "22.3.0-torricelli") + .withQueryStringParameter("nextflow", "26.04"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [ + {"component": "fusion", "version": "2.5", "origin": "published", "yanked": false}, + {"component": "fusion", "version": "2.6", "origin": "published", "yanked": false} + ], + "defaultVersion": "2.6", + "deploymentVersions": {"platform": "22.3.0-torricelli"} + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "info", "versions", "-c", "fusion", "--nextflow", "26.04"); + + assertOutput(format, out, new ComponentVersions("fusion", List.of( + new ComponentVersion("2.5", false), + new ComponentVersion("2.6", true)))); + if (format == OutputType.json) { + assertTrue(out.stdOut.replaceAll("\\s", "").contains("{\"version\":\"2.6\",\"isDefault\":true}"), out.stdOut); + } + } + + @Test + void testVersionsWithoutCatalog(MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "catalogEnabled": false, + "nextflowVersions": [ + {"version": "25.10.1", "image": "nf-launcher:j21-25.10.1"}, + {"version": "26.04.6", "image": "nf-launcher:j21-26.04.6", "default": true} + ] + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "info", "versions"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ComponentVersions("nextflow", List.of( + new ComponentVersion("25.10.1", false), + new ComponentVersion("26.04.6", true))).toString()), out.stdOut); + assertTrue(out.stdOut.contains("26.04.6"), out.stdOut); + } + + @Test + void testVersionsFusionWithoutCatalog(MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"catalogEnabled\":false}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "info", "versions", "-c", "fusion"); + + assertEquals(errorMessage(out.app, new TowerException("The component compatibility catalog is not enabled on this Platform: only Nextflow versions can be listed, without compatibility filters")), out.stdErr); + assertEquals(1, out.exitCode); + } + private String getCliVersion() throws IOException { Properties props = new Properties(); props.load(this.getClass().getResourceAsStream("/META-INF/build-info.properties")); diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index 0904fac1..ec0458d4 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -32,6 +32,7 @@ import io.seqera.tower.cli.responses.studios.StudioDeleted; import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; +import io.seqera.tower.cli.responses.studios.StudioStarred; import io.seqera.tower.cli.responses.studios.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; import io.seqera.tower.cli.responses.studios.StudioUpdated; @@ -2867,6 +2868,58 @@ private void mockWorkspace(MockServerClient mock) { ); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testStar(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"sessionId\": \"3e8370e7\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "star", "-w", "75887156211589", "-i", "3e8370e7"); + + assertOutput(format, out, new StudioStarred("3e8370e7", "[organization1 / workspace1]", true)); + mock.verify(request().withMethod("POST").withPath("/studios/3e8370e7/star"), VerificationTimes.once()); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUnstarByName(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("GET").withPath("/studios").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_list_response")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("DELETE").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "studios", "unstar", "-w", "75887156211589", "-n", "studio-a66d"); + + assertOutput(format, out, new StudioStarred("studio-a66d", "[organization1 / workspace1]", false)); + mock.verify(request().withMethod("DELETE").withPath("/studios/3e8370e7/star"), VerificationTimes.once()); + } + + @Test + void testUnstarNotStarred(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("DELETE").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(404).withBody("{\"message\": \"Studio '3e8370e7' is not starred\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "unstar", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Studio '3e8370e7' is not starred"), out.stdErr); + } + @ParameterizedTest @EnumSource(OutputType.class) void testExtend(OutputType format, MockServerClient mock) {