Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ protected DataStudioConfiguration studioConfigurationFrom(Long wspId, DataStudio

studioConfiguration.setMountData(getMountDataIds(configOptions, studioConfiguration, wspId));

if (configOptions.environment != null) {
studioConfiguration.setEnvironment(configOptions.environment);
}


if (condaEnvOverride != null && !condaEnvOverride.isEmpty()) {
studioConfiguration.setCondaEnvironment(condaEnvOverride);
Expand Down
36 changes: 32 additions & 4 deletions src/main/java/io/seqera/tower/cli/commands/studios/AddCmd.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,21 @@ public class AddCmd extends AbstractStudiosCmd{
@CommandLine.Mixin
public StudioConfigurationOptions studioConfigOptions;

@CommandLine.Mixin
public StudioRemoteConfigOptions remoteConfigOptions;

@CommandLine.Option(names = {"-a", "--auto-start"}, description = "Create studio and start it immediately (default: false)", defaultValue = "false")
public Boolean autoStart;

@CommandLine.Option(names = {"--private"}, description = "Create a private studio that only you can access or manage (default: false)", defaultValue = "false")
public Boolean isPrivate;

@CommandLine.Option(names = {"--spot"}, description = "Launch the studio on spot instances (default: provider/compute environment default).")
public Boolean spot;

@CommandLine.Option(names = {"--ssh"}, description = "Enable SSH connectivity to the studio (default: false).")
public Boolean ssh;

@CommandLine.Option(names = {"--labels"}, description = "Comma-separated list of labels", split = ",", converter = Label.StudioResourceLabelsConverter.class)
public List<Label> labels;

Expand All @@ -83,17 +92,24 @@ public class AddCmd extends AbstractStudiosCmd{
protected Response exec() throws ApiException {
Long wspId = workspaceId(workspace.workspace);

templateValidation(templateOptions, condaEnv, wspId);
templateValidation(templateOptions, remoteConfigOptions, condaEnv, wspId);
DataStudioCreateRequest request = prepareRequest(wspId);
DataStudioCreateResponse response = studiosApi().createDataStudio(request, wspId, autoStart);
DataStudioDto studioDto = response.getStudio();
assert studioDto != null;
return new StudiosCreated(studioDto.getSessionId(), wspId, workspaceRef(wspId), baseWorkspaceUrl(wspId), autoStart);
}

private void templateValidation(StudioTemplateOptions templateOptions, Path condaEnv, Long wspId) throws ApiException {
if (templateOptions.template.standardTemplate != null) {
checkIfTemplateIsAvailable(templateOptions.template.standardTemplate, wspId);
private void templateValidation(StudioTemplateOptions templateOptions, StudioRemoteConfigOptions remoteConfigOptions, Path condaEnv, Long wspId) throws ApiException {
String standardTemplate = templateOptions.template == null ? null : templateOptions.template.standardTemplate;

// A template is mandatory unless a Git repository is provided, since the remote repository may define it.
if (templateOptions.getTemplate() == null && remoteConfigOptions.isEmpty()) {
throw new TowerException("A studio template is required: provide -t/--template or -ct/--custom-template, or a Git repository via -u/--url");
}

if (standardTemplate != null) {
checkIfTemplateIsAvailable(standardTemplate, wspId);
} else if (condaEnv != null) {
throw new StudiosCustomTemplateWithCondaException();
}
Expand All @@ -115,6 +131,15 @@ DataStudioCreateRequest prepareRequest(Long wspId) throws ApiException {
if (description != null && !description.isEmpty()) {request.description(description);}
request.setLabelIds(getLabelIds(labels, wspId));
request.setIsPrivate(isPrivate);
if (spot != null) {
request.setSpot(spot);
}
if (remoteConfigOptions.revision != null && remoteConfigOptions.isEmpty()) {
throw new TowerException("--revision requires --url to be set");
}
if (!remoteConfigOptions.isEmpty()) {
request.setRemoteConfig(remoteConfigOptions.toRemoteConfiguration());
}
request.setDataStudioToolUrl(templateOptions.getTemplate());
ComputeEnvResponseDto ceResponse = computeEnvByRef(wspId, computeEnv);
request.setComputeEnvId(ceResponse.getId());
Expand All @@ -130,6 +155,9 @@ DataStudioCreateRequest prepareRequest(Long wspId) throws ApiException {


DataStudioConfiguration newConfig = studioConfigurationFrom(workspaceId(workspace.workspace), studioConfigOptions, condaEnvString);
if (ssh != null) {
newConfig.setSshEnabled(ssh);
}
request.setConfiguration(setDefaults(newConfig));
return request;
}
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/io/seqera/tower/cli/commands/studios/StartCmd.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public class StartCmd extends AbstractStudiosCmd {
@CommandLine.Option(names = {"--description"}, description = "Optional configuration override for 'description'.")
public String description;

@CommandLine.Option(names = {"--spot"}, description = "Optional override to launch the studio on spot instances.")
public Boolean spot;

@CommandLine.Option(names = {"--ssh"}, description = "Optional override to enable SSH connectivity to the studio.")
public Boolean ssh;

@Override
protected Response exec() throws ApiException {
Long wspId = workspaceId(workspace.workspace);
Expand Down Expand Up @@ -97,6 +103,9 @@ protected Integer onBeforeExit(int exitCode, Response response) {

private DataStudioStartRequest getStartRequestWithOverridesApplied(DataStudioDto studioDto) throws ApiException {
DataStudioConfiguration newConfig = studioConfigurationFrom(studioDto.getWorkspaceId(), studioDto, studioConfigOptions);
if (ssh != null) {
newConfig.setSshEnabled(ssh);
}
String appliedDescription = description == null
? studioDto.getDescription()
: description;
Expand All @@ -106,6 +115,9 @@ private DataStudioStartRequest getStartRequestWithOverridesApplied(DataStudioDto
request.setConfiguration(newConfig);
request.setDescription(appliedDescription);
request.setLabelIds(getLabelIds(labels, studioDto.getWorkspaceId()));
if (spot != null) {
request.setSpot(spot);
}

return request;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package io.seqera.tower.cli.commands.studios;

import java.util.Map;

import picocli.CommandLine;

public class StudioConfigurationOptions {
Expand All @@ -32,6 +34,9 @@ public class StudioConfigurationOptions {
@CommandLine.Option(names = {"--lifespan"}, description = "Optional configuration override for 'lifespan' setting (integer representing hours). Defaults to workspace lifespan setting.")
public Integer lifespan;

@CommandLine.Option(names = {"-e", "--env"}, description = "Add environment variables to the studio as key=value pairs. Can be specified multiple times (e.g. -e KEY1=value1 -e KEY2=value2).")
public Map<String, String> environment;

@CommandLine.Mixin
public DataLinkRefOptions dataLinkRefOptions;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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.model.StudioRemoteConfiguration;
import picocli.CommandLine;

public class StudioRemoteConfigOptions {

@CommandLine.Option(names = {"-u", "--url"}, description = "Git repository URL to import studio configuration from.")
public String url;

@CommandLine.Option(names = {"--revision"}, description = "Optional branch, tag or commit of the Git repository to check out. Requires --url.")
public String revision;

public boolean isEmpty() {
return url == null || url.isEmpty();
}

public StudioRemoteConfiguration toRemoteConfiguration() {
if (isEmpty()) {
return null;
}
StudioRemoteConfiguration remoteConfig = new StudioRemoteConfiguration();
remoteConfig.setRepository(url);
if (revision != null && !revision.isEmpty()) {
remoteConfig.setRevision(revision);
}
return remoteConfig;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

public class StudioTemplateOptions {

@CommandLine.ArgGroup(multiplicity = "1")
@CommandLine.ArgGroup(multiplicity = "0..1")
public StudioTemplate template;

public static class StudioTemplate {
Expand All @@ -32,6 +32,9 @@ public static class StudioTemplate {
}

public String getTemplate() {
if (template == null) {
return null;
}
return template.standardTemplate != null ? template.standardTemplate : template.customTemplate;
}
}
58 changes: 56 additions & 2 deletions src/main/java/io/seqera/tower/cli/commands/studios/UpdateCmd.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
import io.seqera.tower.cli.exceptions.TowerException;
import io.seqera.tower.cli.responses.Response;
import io.seqera.tower.cli.responses.studios.StudioUpdated;
import io.seqera.tower.model.ComputeEnvResponseDto;
import io.seqera.tower.model.DataStudioConfiguration;
import io.seqera.tower.model.DataStudioDto;
import io.seqera.tower.model.DataStudioUpdateRequest;
import io.seqera.tower.model.ListComputeEnvsResponseEntry;
import picocli.CommandLine;

@CommandLine.Command(
Expand All @@ -54,19 +56,39 @@ public class UpdateCmd extends AbstractStudiosCmd {
@CommandLine.Option(names = {"--new-name"}, description = "Optional new name for the studio.")
public String newName;

@CommandLine.Option(names = {"--ssh"}, description = "Optional override to enable or disable SSH connectivity to the studio.")
public Boolean ssh;

@CommandLine.Option(names = {"-c", "--compute-env"}, description = "Move the studio to a different (compatible) compute environment. Only allowed while the studio is stopped.")
public String computeEnv;

@Override
protected Response exec() throws ApiException {
Long wspId = workspaceId(workspace.workspace);

DataStudioDto studioDto;
try {
DataStudioDto studioDto = fetchStudio(studioRefOptions, wspId);
studioDto = fetchStudio(studioRefOptions, wspId);
} catch (ApiException e) {
if (e.getCode() == 404) {
throw new StudioNotFoundException(studioRefOptions.getStudioIdentifier(), workspace.workspace);
}
if (e.getCode() == 403) {
throw new TowerException(String.format("User not entitled to view studio '%s' at %s workspace", studioRefOptions.getStudioIdentifier(), workspace.workspace));
}
throw e;
}

DataStudioUpdateRequest request = getUpdateRequestWithOverridesApplied(studioDto);
DataStudioUpdateRequest request = getUpdateRequestWithOverridesApplied(studioDto);

try {
DataStudioDto updatedStudio = studiosApi().updateDataStudio(studioDto.getSessionId(), request, wspId);

return new StudioUpdated(updatedStudio.getSessionId(), studioRefOptions.getStudioIdentifier(), wspId, workspaceRef(wspId));
} catch (ApiException e) {
if (computeEnv != null && e.getCode() == 400 && isIncompatibleComputeEnvError(e)) {
throw incompatibleComputeEnvException(studioDto.getSessionId(), wspId);
}
if (e.getCode() == 404) {
throw new StudioNotFoundException(studioRefOptions.getStudioIdentifier(), workspace.workspace);
}
Expand All @@ -77,8 +99,35 @@ protected Response exec() throws ApiException {
}
}

private boolean isIncompatibleComputeEnvError(ApiException e) {
String body = e.getResponseBody();
return body != null && body.contains("is not compatible with the Studio's current compute environment");
}

private TowerException incompatibleComputeEnvException(String sessionId, Long wspId) {
StringBuilder message = new StringBuilder(String.format(
"Compute environment '%s' is not compatible with studio '%s'.", computeEnv, studioRefOptions.getStudioIdentifier()));
try {
List<ListComputeEnvsResponseEntry> compatible = studiosApi()
.listDataStudioCompatibleComputeEnvs(sessionId, wspId, null)
.getComputeEnvs();
if (compatible == null || compatible.isEmpty()) {
message.append(" No compatible compute environments are available in this workspace.");
} else {
message.append(" Choose one of the following compatible compute environments:");
compatible.forEach(ce -> message.append(String.format("%n - %s (%s)", ce.getName(), ce.getId())));
}
} catch (ApiException ex) {
message.append(" (unable to retrieve the list of compatible compute environments)");
}
return new TowerException(message.toString());
}

private DataStudioUpdateRequest getUpdateRequestWithOverridesApplied(DataStudioDto studioDto) throws ApiException {
DataStudioConfiguration newConfig = studioConfigurationFrom(studioDto.getWorkspaceId(), studioDto, studioConfigOptions);
if (ssh != null) {
newConfig.setSshEnabled(ssh);
}
String appliedDescription = description == null
? studioDto.getDescription()
: description;
Expand All @@ -93,6 +142,11 @@ private DataStudioUpdateRequest getUpdateRequestWithOverridesApplied(DataStudioD
request.setName(newName);
}

if (computeEnv != null) {
ComputeEnvResponseDto ceResponse = computeEnvByRef(studioDto.getWorkspaceId(), computeEnv);
request.setComputeEnvId(ceResponse.getId());
}

return request;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.seqera.tower.model.DataStudioConfiguration;
import io.seqera.tower.model.DataStudioDto;
import io.seqera.tower.model.DataStudioStatusInfo;
import io.seqera.tower.model.StudioSshDetailsDto;
import io.seqera.tower.model.UserInfo;

import java.io.PrintWriter;
Expand Down Expand Up @@ -66,6 +67,23 @@ public void toString(PrintWriter out){
table.addRow("CPU allocated", config == null ? "-" : String.valueOf(config.getCpu()));
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("Lifespan (hours)", studio.getEffectiveLifespanHours() == null ? "Unlimited" : String.valueOf(studio.getEffectiveLifespanHours()));
table.addRow("SSH enabled", config == null || config.getSshEnabled() == null ? "false" : String.valueOf(config.getSshEnabled()));

StudioSshDetailsDto sshDetails = studio.getSshDetails();
if (sshDetails != null) {
table.addRow("SSH host", sshDetails.getHost());
table.addRow("SSH port", sshDetails.getPort() == null ? "NA" : String.valueOf(sshDetails.getPort()));
table.addRow("SSH command", sshDetails.getCommand());
}

if (config != null && config.getEnvironment() != null && !config.getEnvironment().isEmpty()) {
String envVars = config.getEnvironment().entrySet().stream()
.map(e -> String.format("%s=%s", e.getKey(), e.getValue()))
.collect(java.util.stream.Collectors.joining(", "));
table.addRow("Environment variables", envVars);
}

table.print();
if (config != null && config.getCondaEnvironment() != null && !config.getCondaEnvironment().isEmpty()) {
Expand Down
Loading
Loading