diff --git a/USAGE.md b/USAGE.md index 5062c0e88..153a0c546 100644 --- a/USAGE.md +++ b/USAGE.md @@ -772,12 +772,15 @@ tw lineage upstream -w my-org/my-workspace -i - 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`. List the Nextflow and Fusion versions you can launch with `tw info versions` (`-c fusion` for Fusion). + - Azure Cloud: `--key-vault-url`. Google Cloud: `--billing-export-table`. +- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--fusion-version` and `--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 star|unstar|extend|logs|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`. +- `tw credentials federation-setup -p aws|google`: show the values to configure in your cloud provider before adding workload identity credentials. +- `tw launch --wait` keeps polling through transient errors after a successful submit. If the run's status can't be checked, it exits with code 3 instead of 1. [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 3854e104a..53ad4b87b 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -912,6 +912,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.credentials.FederationSetupCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"provider"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.credentials.ListCmd", "allDeclaredFields":true, @@ -2550,6 +2557,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.CredentialsFederationSetup", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.CredentialsList", "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 83ca26af7..7bbf072a5 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -59,6 +59,7 @@ import io.seqera.tower.cli.exceptions.NoComputeEnvironmentException; import io.seqera.tower.cli.exceptions.OrganizationNotFoundException; import io.seqera.tower.cli.exceptions.ShowUsageException; +import io.seqera.tower.cli.exceptions.StatusCheckFailedException; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.exceptions.WorkspaceNotFoundException; import io.seqera.tower.cli.responses.Response; @@ -700,8 +701,8 @@ public Integer call() { return onBeforeExit(exitCode, response); } catch (Exception e) { errorMessage(app(), e); + return e instanceof StatusCheckFailedException ? StatusCheckFailedException.EXIT_CODE : CommandLine.ExitCode.SOFTWARE; } - return CommandLine.ExitCode.SOFTWARE; } protected Integer onBeforeExit(int exitCode, Response response) throws ApiException { diff --git a/src/main/java/io/seqera/tower/cli/commands/CredentialsCmd.java b/src/main/java/io/seqera/tower/cli/commands/CredentialsCmd.java index d47b1ed83..0f40322ec 100644 --- a/src/main/java/io/seqera/tower/cli/commands/CredentialsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/CredentialsCmd.java @@ -18,6 +18,7 @@ import io.seqera.tower.cli.commands.credentials.AddCmd; import io.seqera.tower.cli.commands.credentials.DeleteCmd; +import io.seqera.tower.cli.commands.credentials.FederationSetupCmd; import io.seqera.tower.cli.commands.credentials.ListCmd; import io.seqera.tower.cli.commands.credentials.UpdateCmd; import io.seqera.tower.cli.commands.credentials.ValidateCmd; @@ -32,7 +33,8 @@ UpdateCmd.class, DeleteCmd.class, ListCmd.class, - ValidateCmd.class + ValidateCmd.class, + FederationSetupCmd.class } ) public class CredentialsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java index 6b3252b4a..c9bd26179 100644 --- a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java @@ -23,6 +23,7 @@ import io.seqera.tower.cli.commands.pipelines.NullableLaunchOptions; import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.exceptions.InvalidResponseException; +import io.seqera.tower.cli.exceptions.StatusCheckFailedException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.runs.RunSubmited; import io.seqera.tower.model.ComputeEnvResponseDto; @@ -89,7 +90,7 @@ public class LaunchCmd extends AbstractRootCmd { @ArgGroup(multiplicity = "0..1") VersionRefOptions.VersionRef versionRef; - @Option(names = {"--wait"}, description = "Wait until workflow reaches specified status: ${COMPLETION-CANDIDATES}") + @Option(names = {"--wait"}, description = "Wait until workflow reaches specified status: ${COMPLETION-CANDIDATES}. Exits with code 3 if the run was submitted but its status could not be checked.") public WorkflowStatus wait; @Option(names = {"-l", "--labels"}, split = ",", description = "Labels to assign to each pipeline run. Provide comma-separated label values (use key=value format for resource labels). Labels will be created if they don't exist", converter = Label.LabelConverter.class) @@ -250,18 +251,22 @@ protected Integer onBeforeExit(int exitCode, Response response) { () -> checkWorkflowStatus(submitted.workflowId, submitted.workspaceId), WorkflowStatus.CANCELLED, WorkflowStatus.FAILED, WorkflowStatus.SUCCEEDED ); + } catch (StatusCheckFailedException e) { + String workspaceOption = submitted.workspaceId == null ? "" : " -w " + submitted.workspaceId; + throw new StatusCheckFailedException(String.format("Run %s was submitted but its status could not be checked (%s). It may still be running, check it with: tw runs view -i %s%s", submitted.workflowId, e.getReason(), submitted.workflowId, workspaceOption), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return exitCode; } } - private WorkflowStatus checkWorkflowStatus(String workflowId, Long workspaceId) { - try { - return workflowsApi().describeWorkflow(workflowId, workspaceId, NO_WORKFLOW_ATTRIBUTES).getWorkflow().getStatus(); - } catch (ApiException | NullPointerException e) { - return null; + private WorkflowStatus checkWorkflowStatus(String workflowId, Long workspaceId) throws ApiException { + WorkflowStatus status = workflowsApi().describeWorkflow(workflowId, workspaceId, NO_WORKFLOW_ATTRIBUTES).getWorkflow().getStatus(); + // Platform lost contact with the run, which may still be running: keep polling as for a failed check + if (status == WorkflowStatus.UNKNOWN) { + throw new IllegalStateException("run status is UNKNOWN"); } + return status; } private List obtainLabelIDs(@Nullable Long workspaceId) throws ApiException { diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/DeleteCmd.java index 3b5f2f34b..16940a8ce 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/DeleteCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/DeleteCmd.java @@ -86,11 +86,7 @@ protected Integer onBeforeExit(int exitCode, Response response) { } } - private ComputeEnvStatus checkComputeEnvStatus(String computeEnvId, Long workspaceId) { - try { - return computeEnvsApi().describeComputeEnv(computeEnvId, workspaceId, Collections.emptyList()).getComputeEnv().getStatus(); - } catch (ApiException | NullPointerException e) { - return null; - } + private ComputeEnvStatus checkComputeEnvStatus(String computeEnvId, Long workspaceId) throws ApiException { + return computeEnvsApi().describeComputeEnv(computeEnvId, workspaceId, Collections.emptyList()).getComputeEnv().getStatus(); } } \ No newline at end of file diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/add/AbstractAddCmd.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/add/AbstractAddCmd.java index 8c0e5c07b..78d1df62f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/add/AbstractAddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/add/AbstractAddCmd.java @@ -94,12 +94,8 @@ protected Integer onBeforeExit(int exitCode, Response response) { } } - private ComputeEnvStatus checkComputeEnvStatus(String computeEnvId, Long workspaceId) { - try { - return computeEnvsApi().describeComputeEnv(computeEnvId, workspaceId, Collections.emptyList()).getComputeEnv().getStatus(); - } catch (ApiException | NullPointerException e) { - return null; - } + private ComputeEnvStatus checkComputeEnvStatus(String computeEnvId, Long workspaceId) throws ApiException { + return computeEnvsApi().describeComputeEnv(computeEnvId, workspaceId, Collections.emptyList()).getComputeEnv().getStatus(); } protected ComputeEnvAdded addComputeEnv(PlatformEnum platform, ComputeConfig config) throws ApiException { diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java index 75bdd801a..72f0d88d3 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java @@ -92,6 +92,11 @@ public AzCloudConfig computeConfig() throws ApiException, IOException { .logTableName(adv.logTableName) .dataCollectionEndpoint(adv.dataCollectionEndpoint) .dataCollectionRuleId(adv.dataCollectionRuleId); + + // keyVaultUrl is JsonNullable: setting null would send an explicit null. + if (adv.keyVaultUrl != null) { + config.keyVaultUrl(adv.keyVaultUrl); + } } // Common @@ -125,6 +130,9 @@ public static class AdvancedOptions { @Option(names = {"--instance-type"}, description = "Azure virtual machine size (e.g., Standard_D2s_v3, Standard_E4s_v3). If omitted, a default VM size is used.") public String instanceType; + @Option(names = {"--key-vault-url"}, description = "Azure Key Vault that stores pipeline secrets, in the form https://.vault.azure.net. Only used with --sched-enabled. If absent, the default Key Vault is used, if one is configured.") + public String keyVaultUrl; + @Option(names = {"--log-table-name"}, description = "Custom table name in Log Analytics workspace for storing compute environment logs. Enables organized log management.") public String logTableName; diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java index eeea3031f..c763a7903 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java @@ -82,6 +82,10 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { if (sched.machineTypes != null) { schedConfig.machineTypes(sched.machineTypes); } + // billingExportTable is JsonNullable: setting null would send an explicit null. + if (sched.billingExportTable != null) { + schedConfig.billingExportTable(sched.billingExportTable); + } config.schedConfig(schedConfig); } @@ -148,6 +152,9 @@ public static class SchedOptions { @Option(names = {"--sched-machine-types"}, description = "Compute Engine machine types for compute nodes managed by the Seqera scheduler. Comma-separated list (e.g., n2-standard-4,c2-standard-8). Leave empty to let the scheduler select the most cost-effective types.", split = ",") public List machineTypes; + + @Option(names = {"--billing-export-table"}, description = "Fully-qualified BigQuery table holding the Cloud Billing export, as project.dataset.table. Enables cost reporting for this compute environment. Costs are unavailable for runs that predate the export.") + public String billingExportTable; } public static class AdvancedOptions { diff --git a/src/main/java/io/seqera/tower/cli/commands/credentials/FederationSetupCmd.java b/src/main/java/io/seqera/tower/cli/commands/credentials/FederationSetupCmd.java new file mode 100644 index 000000000..879ce04bc --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/credentials/FederationSetupCmd.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.CredentialsFederationSetup; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.CredentialsSetupValuesResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "federation-setup", + description = "Show the values to configure in your cloud provider before adding workload identity credentials" +) +public class FederationSetupCmd extends AbstractCredentialsCmd { + + // Platform returns no values, rather than an error, for any other provider + public enum Provider { aws, google } + + @Option(names = {"-p", "--provider"}, description = "Credentials provider: ${COMPLETION-CANDIDATES}.", required = true) + public Provider provider; + + @Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + CredentialsSetupValuesResponse response = credentialsApi().describeCredentialsSetup(provider.name(), wspId); + return new CredentialsFederationSetup(provider.name(), workspaceRef(wspId), response.getSetupValues()); + } +} 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 dc2c78c23..9d61cd07a 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 @@ -43,6 +43,9 @@ public class NullableLaunchOptions { @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 = {"--fusion-version"}, description = "Fusion version to run the workflow with. Must exist in the Platform version catalog and only applies when the compute environment enables Fusion v2 (see 'tw info versions -c fusion'). Takes precedence over the value stored in the launch configuration.") + public String fusionVersion; + @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.") public String outputDir; @@ -53,6 +56,9 @@ public void applyNullableOptions(WorkflowLaunchRequest request) { if (nextflowVersion != null) { request.nextflowVersion(nextflowVersion); } + if (fusionVersion != null) { + request.fusionVersion(fusionVersion); + } if (outputDir != null) { request.outputDir(outputDir); } 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 55daba69e..d84894a8f 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 @@ -152,13 +152,9 @@ protected Integer onBeforeExit(int exitCode, String sessionId, Long workspaceId, } } - protected DataStudioStatus checkStudioStatus(String sessionId, Long workspaceId) { - try { - DataStudioStatusInfo statusInfo = studiosApi().describeDataStudio(sessionId, workspaceId).getStatusInfo(); - return statusInfo == null ? null : statusInfo.getStatus(); - } catch (ApiException e) { - return null; - } + protected DataStudioStatus checkStudioStatus(String sessionId, Long workspaceId) throws ApiException { + DataStudioStatusInfo statusInfo = studiosApi().describeDataStudio(sessionId, workspaceId).getStatusInfo(); + return statusInfo == null ? null : statusInfo.getStatus(); } protected DataStudioConfiguration studioConfigurationFrom(Long wspId, StudioConfigurationOptions configurationOptions, String condaEnvOverride) throws ApiException { diff --git a/src/main/java/io/seqera/tower/cli/exceptions/StatusCheckFailedException.java b/src/main/java/io/seqera/tower/cli/exceptions/StatusCheckFailedException.java new file mode 100644 index 000000000..dfd1c2b78 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/exceptions/StatusCheckFailedException.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.exceptions; + +/** + * The resource was created or submitted, but polling its status kept failing, so its outcome is unknown. + */ +public class StatusCheckFailedException extends TowerRuntimeException { + + // Distinct from 1 so automation does not mistake "status unknown" for "failed" and retry, creating duplicates + public static final int EXIT_CODE = 3; + + private final String reason; + + public StatusCheckFailedException(String reason, Throwable cause) { + super(String.format("Status could not be checked (%s)", reason), cause); + this.reason = reason; + } + + public StatusCheckFailedException(String message, StatusCheckFailedException failure) { + super(message, failure.getCause()); + this.reason = failure.reason; + } + + public String getReason() { + return reason; + } + +} diff --git a/src/main/java/io/seqera/tower/cli/responses/CredentialsFederationSetup.java b/src/main/java/io/seqera/tower/cli/responses/CredentialsFederationSetup.java new file mode 100644 index 000000000..1b608d626 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/CredentialsFederationSetup.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 io.seqera.tower.model.CredentialsSetupValue; + +import java.io.PrintWriter; +import java.util.List; + +public class CredentialsFederationSetup extends Response { + + public final String provider; + public final String workspaceRef; + public final List setupValues; + + public CredentialsFederationSetup(String provider, String workspaceRef, List setupValues) { + this.provider = provider; + this.workspaceRef = workspaceRef; + this.setupValues = setupValues; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Workload identity setup values for '%s' credentials at %s workspace:|@%n", provider, workspaceRef))); + if (setupValues == null || setupValues.isEmpty()) { + out.println(ansi(" @|yellow No setup values for this provider|@")); + return; + } + + TableList table = new TableList(out, 2); + table.setPrefix(" "); + setupValues.forEach(value -> table.addRow(value.getLabel(), value.getValue())); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/utils/ModelHelper.java b/src/main/java/io/seqera/tower/cli/utils/ModelHelper.java index 1eb5bd6cd..4fa79cf77 100644 --- a/src/main/java/io/seqera/tower/cli/utils/ModelHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/ModelHelper.java @@ -53,7 +53,7 @@ public static WorkflowLaunchRequest createLaunchRequest(WorkflowLaunchResponse l * API gains in a future SDK release are carried over with no change here. *

* Going through JSON is what makes this safe for the nullable fields ({@code syntaxParser}, - * {@code nextflowVersion}, {@code outputDir}): an undefined value stays undefined instead of becoming an + * {@code nextflowVersion}, {@code fusionVersion}, {@code outputDir}): an undefined value stays undefined instead of becoming an * explicit {@code null}, which the fluent setters cannot express because they wrap their argument in * {@code JsonNullable.of()} unconditionally. */ diff --git a/src/main/java/io/seqera/tower/cli/utils/ResponseHelper.java b/src/main/java/io/seqera/tower/cli/utils/ResponseHelper.java index 941ae428b..db2c96b28 100644 --- a/src/main/java/io/seqera/tower/cli/utils/ResponseHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/ResponseHelper.java @@ -22,6 +22,7 @@ import io.seqera.tower.cli.commands.enums.OutputType; import io.seqera.tower.cli.exceptions.ApiExceptionMessage; import io.seqera.tower.cli.exceptions.ShowUsageException; +import io.seqera.tower.cli.exceptions.StatusCheckFailedException; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.exceptions.TowerRuntimeException; import io.seqera.tower.cli.responses.Response; @@ -30,11 +31,14 @@ import javax.ws.rs.ProcessingException; import java.io.PrintWriter; import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @@ -43,6 +47,9 @@ public class ResponseHelper { + // Covers a backend incident window (e.g. a DB pool exhaustion) without polling a dead server forever + private static final Duration MAX_FAILING_POLLS = Duration.ofMinutes(10); + private ResponseHelper() { } @@ -134,11 +141,17 @@ public static String decodeMessage(ApiException ex) { } - public static > Integer waitStatus(PrintWriter out, boolean showProgress, S targetStatus, S[] allStates, Supplier checkStatus, S... endStates ) throws InterruptedException { + public static > Integer waitStatus(PrintWriter out, boolean showProgress, S targetStatus, S[] allStates, Callable checkStatus, S... endStates ) throws InterruptedException { return waitStatus(out, showProgress, null, targetStatus, allStates, checkStatus, endStates); } - public static > Integer waitStatus(PrintWriter out, boolean showProgress, Supplier additionalProgressSteps, S targetStatus, S[] allStates, Supplier checkStatus, S... endStates ) throws InterruptedException { + /** + * Polls {@code checkStatus} until it reports {@code targetStatus} or a later/end state. A failed poll or a status + * outside {@code allStates} keeps polling: the resource already exists, so reporting it as failed would make + * callers retry and duplicate it. Polling gives up with {@link StatusCheckFailedException} on an authorization or + * not-found error, or once polls have kept failing for {@link #MAX_FAILING_POLLS}. + */ + public static > Integer waitStatus(PrintWriter out, boolean showProgress, Supplier additionalProgressSteps, S targetStatus, S[] allStates, Callable checkStatus, S... endStates ) throws InterruptedException { Map positions = new HashMap<>(); for (int i=0; i < allStates.length; i++) { @@ -150,19 +163,44 @@ public static > Integer waitStatus(PrintWriter out, boolean sh int secondsToSleep = 2; int maxSecondsToSleep = 120; int targetPos = positions.get(targetStatus); - int currentPos; + int currentPos = -1; + S status = null; S lastReported = null; + Instant failingSince = null; if (showProgress) { out.print(String.format(" Waiting %s status...", targetStatus)); out.flush(); } - S status; do { TimeUnit.SECONDS.sleep(secondsToSleep); - status = checkStatus.get(); - currentPos = status == null ? positions.size() : positions.get(status); + if (secondsToSleep < maxSecondsToSleep) { + secondsToSleep += 1; + } + + try { + status = checkStatus.call(); + failingSince = null; + } catch (InterruptedException e) { + throw e; + } catch (Exception e) { + failingSince = failingSince == null ? Instant.now() : failingSince; + if (isPermanentFailure(e) || Duration.between(failingSince, Instant.now()).compareTo(MAX_FAILING_POLLS) > 0) { + if (showProgress) { + out.print(" [UNKNOWN]\n\n"); + out.flush(); + } + throw new StatusCheckFailedException(describeFailure(e), e); + } + if (showProgress) { + out.print('.'); + out.flush(); + } + continue; + } + + currentPos = positions.getOrDefault(status, currentPos); if (showProgress) { out.print('.'); if (lastReported != status) { @@ -177,9 +215,6 @@ public static > Integer waitStatus(PrintWriter out, boolean sh } out.flush(); } - if (secondsToSleep < maxSecondsToSleep) { - secondsToSleep += 1; - } } while (currentPos < targetPos && !immutableStates.contains(status)); if (showProgress) { @@ -193,4 +228,19 @@ public static > Integer waitStatus(PrintWriter out, boolean sh return currentPos == targetPos ? CommandLine.ExitCode.OK : CommandLine.ExitCode.SOFTWARE; } + private static boolean isPermanentFailure(Exception e) { + return e instanceof ApiException api && (api.getCode() == 401 || api.getCode() == 403 || api.getCode() == 404); + } + + private static String describeFailure(Exception e) { + if (e instanceof ApiException api && api.getCode() > 0) { + return "HTTP " + api.getCode(); + } + Throwable root = e; + while (root.getCause() != null) { + root = root.getCause(); + } + return root.getMessage() != null ? root.getMessage() : root.getClass().getSimpleName(); + } + } diff --git a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java index 2cbf84a59..98d138059 100644 --- a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java @@ -22,12 +22,14 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.enums.OutputType; import io.seqera.tower.cli.exceptions.InvalidResponseException; +import io.seqera.tower.cli.exceptions.StatusCheckFailedException; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.runs.RunSubmited; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.mockserver.client.MockServerClient; +import org.mockserver.model.HttpResponse; import org.mockserver.model.MediaType; import org.mockserver.verify.VerificationTimes; @@ -36,6 +38,7 @@ import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME; import static io.seqera.tower.cli.commands.AbstractApiCmd.buildWorkspaceRef; 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; @@ -165,6 +168,7 @@ void testSubmitLaunchpadPipelineOverridingNullableOptions(MockServerClient mock) "pipeline":"https://github.com/nf-core/sarek", "syntaxParser":"v1", "nextflowVersion":"25.10.1", + "fusionVersion":"2.6", "outputDir":"/new-outputs" } }""" @@ -182,7 +186,7 @@ void testSubmitLaunchpadPipelineOverridingNullableOptions(MockServerClient mock) // Run the command ExecOut out = exec(mock, "launch", "sarek", "--syntax-parser", "v1", - "--nextflow-version", "25.10.1", "--output-dir", "/new-outputs"); + "--nextflow-version", "25.10.1", "--fusion-version", "2.6", "--output-dir", "/new-outputs"); // Assert results assertEquals("", out.stdErr); @@ -859,4 +863,81 @@ void testSubmitLaunchpadPipelineWithVersionNameNotFound(MockServerClient mock) { assertEquals(1, out.exitCode); } + + @Test + void testWaitKeepsPollingAfterTransientError(MockServerClient mock) { + mockGithubLaunch(mock); + mockWorkflowStatusResponse(mock, response().withStatusCode(502)); + mockWorkflowStatusResponse(mock, workflowStatus("SUCCEEDED")); + + ExecOut out = exec(OutputType.json, mock, "launch", "https://github.com/nextflow-io/hello", "--wait=SUCCEEDED"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testWaitKeepsPollingAfterUnrecognizedStatus(MockServerClient mock) { + mockGithubLaunch(mock); + mockWorkflowStatusResponse(mock, workflowStatus("PAUSED")); + mockWorkflowStatusResponse(mock, workflowStatus("SUCCEEDED")); + + ExecOut out = exec(OutputType.json, mock, "launch", "https://github.com/nextflow-io/hello", "--wait=SUCCEEDED"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testWaitReportsUncheckedStatusWithDistinctExitCode(MockServerClient mock) { + mockGithubLaunch(mock); + mockWorkflowStatusResponse(mock, response().withStatusCode(404)); + + ExecOut out = exec(OutputType.json, mock, "launch", "https://github.com/nextflow-io/hello", "--wait=SUCCEEDED"); + + assertTrue(out.stdOut.contains("57ojrWRzTyous"), "workflow id is printed before polling"); + assertTrue(out.stdErr.contains("Run 57ojrWRzTyous was submitted but its status could not be checked (HTTP 404). It may still be running, check it with: tw runs view -i 57ojrWRzTyous"), out.stdErr); + assertEquals(StatusCheckFailedException.EXIT_CODE, out.exitCode); + } + + @Test + void testWaitFailsWhenRunFails(MockServerClient mock) { + mockGithubLaunch(mock); + mockWorkflowStatusResponse(mock, workflowStatus("FAILED")); + + ExecOut out = exec(OutputType.json, mock, "launch", "https://github.com/nextflow-io/hello", "--wait=SUCCEEDED"); + + assertEquals(1, out.exitCode); + } + + private void mockGithubLaunch(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"1uJweHHZTo7gydE6pyDt7x\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"primary\":true}]}").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/compute-envs/1uJweHHZTo7gydE6pyDt7x"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnv\":{\"id\":\"1uJweHHZTo7gydE6pyDt7x\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"config\":{\"discriminator\":\"aws-batch\",\"workDir\":\"s3://nextflow-ci/jordeu\"}}}").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("POST").withPath("/workflow/launch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"workflowId\":\"57ojrWRzTyous\"}").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + } + + private static void mockWorkflowStatusResponse(MockServerClient mock, HttpResponse response) { + mock.when(request().withMethod("GET").withPath("/workflow/57ojrWRzTyous"), exactly(1)).respond(response); + } + + private static HttpResponse workflowStatus(String status) { + return response().withStatusCode(200).withBody(String.format("{\"workflow\":{\"id\":\"57ojrWRzTyous\",\"status\":\"%s\"}}", status)).withContentType(MediaType.APPLICATION_JSON); + } } diff --git a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java index a86a8d111..5b42114b1 100644 --- a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java @@ -375,7 +375,7 @@ void testAdd(OutputType format, MockServerClient mock) { } @Test - void testAddWithTowerConfig(MockServerClient mock) throws IOException { + void testAddWithFusionVersionAndTowerConfig(MockServerClient mock) throws IOException { mock.reset(); mock.when( @@ -392,13 +392,13 @@ void testAddWithTowerConfig(MockServerClient mock) throws IOException { mock.when( request().withMethod("POST").withPath("/actions") - .withBody(json("{\"launch\":{\"towerConfig\":\"reports: {}\"}}")), exactly(1) + .withBody(json("{\"launch\":{\"fusionVersion\":\"2.6\",\"towerConfig\":\"reports: {}\"}}")), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("/actions/action_add")).withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "actions", "add", "github", "-n", "new-action", "--pipeline", "https://github.com/pditommaso/nf-sleep", - "--tower-config", tempFile("reports: {}", "tower", "yml")); + "--fusion-version", "2.6", "--tower-config", tempFile("reports: {}", "tower", "yml")); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java index 91de216b3..1ac5bad31 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java @@ -242,4 +242,71 @@ void testAddWithScheduler(MockServerClient mock) throws IOException { assertEquals(0, out.exitCode); assertEquals(expected.toString(), out.stdOut); } + + @Test + void testAddWithKeyVaultUrl(MockServerClient mock) throws IOException { + mock.reset(); + + // given + mock.when( + request() + .withMethod("GET") + .withPath("/credentials") + .withQueryStringParameter("platformId", "azure-cloud"), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"azure\",\"description\":null,\"discriminator\":\"azure\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") + ); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-azure-cloud-kv", + "platform": "azure-cloud", + "config": { + "workDir": "az://my-container", + "region": "eastus", + "resourceGroup": "my-resource-group", + "fusion2Enabled": true, + "waveEnabled": true, + "keyVaultUrl": "https://my-vault.vault.azure.net", + "schedEnabled": true + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + // when + ExecOut out = exec(mock, "compute-envs", "add", "azure-cloud", + "-n", "my-azure-cloud-kv", + "--work-dir", + "az://my-container", + "-r", + "eastus", + "--resource-group", + "my-resource-group", + "--sched-enabled", + "--key-vault-url", "https://my-vault.vault.azure.net" + ); + + // then + var expected = new ComputeEnvAdded("azure-cloud", "isnEDBLvHDAIteOEF44ow", "my-azure-cloud-kv", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java index 05b347fc9..1494b313d 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java @@ -381,4 +381,74 @@ private static void mockCredentials(MockServerClient mock) { .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"description\":null,\"discriminator\":\"google\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") ); } + + @Test + void testAddWithBillingExportTable(MockServerClient mock) throws IOException { + mock.reset(); + + // given + mock.when( + request() + .withMethod("GET") + .withPath("/credentials") + .withQueryStringParameter("platformId", "google-cloud"), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"description\":null,\"discriminator\":\"google\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") + ); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-google-cloud-bt", + "platform": "google-cloud", + "config": { + "workDir": "gs://my-bucket", + "region": "us-central1", + "zone": "us-central1-a", + "fusion2Enabled": true, + "waveEnabled": true, + "schedEnabled": true, + "schedConfig": { + "billingExportTable": "my-project.billing.gcp_billing_export_v1_01F197_9D74B5_BC674D" + } + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + // when + ExecOut out = exec(mock, "compute-envs", "add", "google-cloud", + "-n", "my-google-cloud-bt", + "--work-dir", + "gs://my-bucket", + "-r", + "us-central1", + "-z", + "us-central1-a", + "--sched-enabled", + "--billing-export-table", + "my-project.billing.gcp_billing_export_v1_01F197_9D74B5_BC674D" + ); + + // then + var expected = new ComputeEnvAdded("google-cloud", "isnEDBLvHDAIteOEF44ow", "my-google-cloud-bt", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } } diff --git a/src/test/java/io/seqera/tower/cli/credentials/FederationSetupCmdTest.java b/src/test/java/io/seqera/tower/cli/credentials/FederationSetupCmdTest.java new file mode 100644 index 000000000..312889b1d --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/credentials/FederationSetupCmdTest.java @@ -0,0 +1,81 @@ +/* + * 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.credentials; + +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.CredentialsFederationSetup; +import io.seqera.tower.model.CredentialsSetupValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.MediaType; +import org.mockserver.verify.VerificationTimes; + +import java.util.List; + +import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +class FederationSetupCmdTest extends BaseCmdTest { + + @ParameterizedTest + @EnumSource(OutputType.class) + void testFederationSetup(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/credentials/federation-setup").withQueryStringParameter("provider", "aws"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"setupValues\":[" + + "{\"label\":\"Platform public address\",\"value\":\"https://cloud.seqera.io\"}," + + "{\"label\":\"Audience\",\"value\":\"sts.amazonaws.com\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "credentials", "federation-setup", "-p", "aws"); + + assertOutput(format, out, new CredentialsFederationSetup("aws", USER_WORKSPACE_NAME, List.of( + new CredentialsSetupValue().label("Platform public address").value("https://cloud.seqera.io"), + new CredentialsSetupValue().label("Audience").value("sts.amazonaws.com") + ))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testFederationSetupWithoutValues(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/credentials/federation-setup").withQueryStringParameter("provider", "google"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"setupValues\":[]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "credentials", "federation-setup", "-p", "google"); + + assertOutput(format, out, new CredentialsFederationSetup("google", USER_WORKSPACE_NAME, List.of())); + } + + @Test + void testFederationSetupRejectsUnknownProvider(MockServerClient mock) { + ExecOut out = exec(mock, "credentials", "federation-setup", "-p", "azure"); + + assertEquals(2, out.exitCode); + mock.verify(request().withPath("/credentials/federation-setup"), VerificationTimes.never()); + } +} diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java index b3f8577e2..27dcd18ae 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -1210,6 +1210,7 @@ void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) throws IOExce "launch":{ "pipeline":"https://github.com/pditommaso/nf-sleep", "nextflowVersion":"26.04.6", + "fusionVersion":"2.6", "towerConfig":"reports: {}", "outputDir":"s3://nextflow-ci/outputs" } @@ -1220,7 +1221,7 @@ void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) throws IOExce ); ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", - "--nextflow-version", "26.04.6", "--output-dir", "s3://nextflow-ci/outputs", + "--nextflow-version", "26.04.6", "--fusion-version", "2.6", "--output-dir", "s3://nextflow-ci/outputs", "--tower-config", tempFile("reports: {}", "tower", "yml"), "https://github.com/pditommaso/nf-sleep"); @@ -1274,7 +1275,7 @@ void testUpdateKeepsStoredConfigurationLabelsAndIcon(MockServerClient mock) { .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"launch\":{\"id\":\"5nmCvXcarkvv8tELMF4KyY\",\"computeEnvId\":null,\"computeEnv\":{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\"},\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"revision\":\"main\",\"syntaxParser\":\"v2\",\"nextflowVersion\":\"26.04.6\",\"towerConfig\":\"reports: {}\"}}") + .withBody("{\"launch\":{\"id\":\"5nmCvXcarkvv8tELMF4KyY\",\"computeEnvId\":null,\"computeEnv\":{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\"},\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"revision\":\"main\",\"syntaxParser\":\"v2\",\"nextflowVersion\":\"26.04.6\",\"fusionVersion\":\"2.5\",\"towerConfig\":\"reports: {}\"}}") ); mock.when( @@ -1289,6 +1290,7 @@ void testUpdateKeepsStoredConfigurationLabelsAndIcon(MockServerClient mock) { "revision":"main", "syntaxParser":"v2", "nextflowVersion":"26.04.6", + "fusionVersion":"2.5", "towerConfig":"reports: {}" } }""")), exactly(1) diff --git a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java index efe938f42..f99622b67 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -676,6 +676,7 @@ void testRelaunchKeepsOriginalLaunchConfiguration(MockServerClient mock) { "pipelineSchemaId":42, "resume":true, "nextflowVersion":"26.04.6", + "fusionVersion":"2.5", "towerConfig":"reports: {}", "outputDir":"/outputs", "syntaxParser":"v2" @@ -723,7 +724,7 @@ void testRelaunchOverridingSyntaxParser(MockServerClient mock) { } @Test - void testRelaunchOverridingTowerConfig(MockServerClient mock) throws IOException { + void testRelaunchOverridingFusionVersionAndTowerConfig(MockServerClient mock) throws IOException { mock.reset(); mock.when( @@ -750,13 +751,13 @@ void testRelaunchOverridingTowerConfig(MockServerClient mock) throws IOException response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "runs", "relaunch", "-i", "5mDfiUtqyptDib", + ExecOut out = exec(mock, "runs", "relaunch", "-i", "5mDfiUtqyptDib", "--fusion-version", "2.6", "--tower-config", tempFile("reports: []", "tower", "yml")); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); mock.verify(request().withMethod("POST").withPath("/workflow/launch") - .withBody(json("{\"launch\":{\"towerConfig\":\"reports: []\"}}")), VerificationTimes.exactly(1)); + .withBody(json("{\"launch\":{\"fusionVersion\":\"2.6\",\"towerConfig\":\"reports: []\"}}")), VerificationTimes.exactly(1)); } @Test diff --git a/src/test/resources/runcmd/runs/workflow_launch_v2.json b/src/test/resources/runcmd/runs/workflow_launch_v2.json index 65bd06705..26ceab856 100644 --- a/src/test/resources/runcmd/runs/workflow_launch_v2.json +++ b/src/test/resources/runcmd/runs/workflow_launch_v2.json @@ -26,6 +26,7 @@ "pullLatest": false, "stubRun": false, "nextflowVersion": "26.04.6", + "fusionVersion": "2.5", "outputDir": "/outputs", "syntaxParser": "v2", "resumeDir": "s3://fusionfs/scratch/5qRNYT6iAJfRbJ",