From be50a4ed252b4c91a3d030f963e9f1cb470ebb2d Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:14 +0200 Subject: [PATCH 1/7] fix(launch): keep waiting through failed status polls after submit [COMP-2645] tw launch --wait exited 1 after a successful submit when a single describeWorkflow poll failed: checkWorkflowStatus turned any ApiException into null, waitStatus mapped null past every target, and the loop ended as [ERROR]. An unknown status value threw ProcessingException, which escaped as "Connection error". Automation that retries on a non-zero exit then launched the pipeline again while the first run kept going, duplicating runs and compute spend. A single transient 400/5xx was enough, e.g. during the 2026-10-01 Cloud DB pool exhaustion. waitStatus now polls a Callable. A failed poll or an unrecognized status keeps polling with the existing backoff. It gives up only on 401/403/404 or after polls have kept failing for 10 minutes, and then throws StatusCheckFailedException, which exits with code 3 instead of 1. A run in UNKNOWN status is treated as a failed poll, since Platform lost contact but the run may still be running. Launch reports the run id and a tw runs view hint. Exit 1 is now only for runs that actually ended in another end state. The compute-env add/delete and studio waits share the same loop. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/AbstractApiCmd.java | 3 +- .../seqera/tower/cli/commands/LaunchCmd.java | 17 ++-- .../cli/commands/computeenvs/DeleteCmd.java | 8 +- .../computeenvs/add/AbstractAddCmd.java | 8 +- .../commands/studios/AbstractStudiosCmd.java | 10 +-- .../StatusCheckFailedException.java | 43 ++++++++++ .../tower/cli/utils/ResponseHelper.java | 68 +++++++++++++--- .../io/seqera/tower/cli/LaunchCmdTest.java | 80 +++++++++++++++++++ 8 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 src/main/java/io/seqera/tower/cli/exceptions/StatusCheckFailedException.java 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/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/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/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..1e3425c67 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; @@ -859,4 +862,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); + } } From 52389d2f5161fb1f31943841a7b4b018245558d2 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:15 +0200 Subject: [PATCH 2/7] feat(launch): add --fusion-version [COMP-2645] WorkflowLaunchRequest gained fusionVersion in API 1.230.0 (Component Compatibility Catalog, COMP-2121), but it could not be set from the CLI. It is the Fusion version to run the workflow with; it must exist in the Platform catalog and only applies when the compute environment enables Fusion v2. --fusion-version is JsonNullable on the request, so it joins NullableLaunchOptions and is applied only when given, in launch, pipelines add|update, actions add|update and runs relaunch; the guard test covers it automatically. A stored value is inherited by pipelines update and runs relaunch through ModelHelper's JSON copy; the tests now assert that. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/commands/pipelines/NullableLaunchOptions.java | 6 ++++++ src/main/java/io/seqera/tower/cli/utils/ModelHelper.java | 2 +- src/test/java/io/seqera/tower/cli/LaunchCmdTest.java | 3 ++- .../java/io/seqera/tower/cli/actions/ActionsCmdTest.java | 6 +++--- .../io/seqera/tower/cli/pipelines/PipelinesCmdTest.java | 6 ++++-- src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java | 7 ++++--- src/test/resources/runcmd/runs/workflow_launch_v2.json | 1 + 7 files changed, 21 insertions(+), 10 deletions(-) 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/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/test/java/io/seqera/tower/cli/LaunchCmdTest.java b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java index 1e3425c67..98d138059 100644 --- a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java @@ -168,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" } }""" @@ -185,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); 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/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", From 882faf6c465786bce279dda01f64313f780097e9 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:15 +0200 Subject: [PATCH 3/7] feat(credentials): add credentials federation-setup command [COMP-2645] API 1.230 added GET /credentials/federation-setup, which returns the values an administrator must configure in AWS or Google Cloud before creating workload identity credentials: the Platform public address, audience and the OIDC subject claims Platform mints for the workspace. tw added workload identity credentials in e6a8fb91 but users had no way to get these values from the CLI. Platform switches on 'aws' and 'google' exactly and returns no values for anything else, so a typo such as '-p AWS' would print 'No setup values', which reads as nothing to configure. The provider is an enum, so picocli rejects unknown values. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/CredentialsCmd.java | 4 +- .../credentials/FederationSetupCmd.java | 50 ++++++++++++ .../responses/CredentialsFederationSetup.java | 52 ++++++++++++ .../credentials/FederationSetupCmdTest.java | 81 +++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/credentials/FederationSetupCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/CredentialsFederationSetup.java create mode 100644 src/test/java/io/seqera/tower/cli/credentials/FederationSetupCmdTest.java 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/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/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/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()); + } +} From 009f825d1aaabb31cb97d6973e72e4a9f3cc8625 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:16 +0200 Subject: [PATCH 4/7] feat(compute-envs): add --key-vault-url to azure-cloud [COMP-2645] AzCloudConfig.keyVaultUrl (API 1.229.0) selects a per-CE Key Vault for pipeline secrets (COMP-2043). tw never set it, so Azure Cloud secrets fell back to an installation default that Cloud does not set. Only Intelligent Compute resolves these secrets, so the option documents that it is only used with --sched-enabled. keyVaultUrl is JsonNullable, so it is set only when given: setting null would send an explicit null. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platforms/AzCloudPlatform.java | 8 +++ .../platforms/AzCloudPlatformTest.java | 67 +++++++++++++++++++ 2 files changed, 75 insertions(+) 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/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); + } } From 3f5d573380b29001a01e8c477fab462713ed9964 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:16 +0200 Subject: [PATCH 5/7] feat(compute-envs): add --billing-export-table to google-cloud [COMP-2645] API 1.230.0 added SchedConfig.billingExportTable: the BigQuery table holding the Cloud Billing export, which enables cost reporting for an Intelligent Compute CE. tw could not set it. Platform rejects it on other providers, so only google-cloud exposes the option, matching the tower-web form. billingExportTable is JsonNullable, so it is set only when given: setting null would send an explicit null. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platforms/GoogleCloudPlatform.java | 7 ++ .../platforms/GoogleCloudPlatformTest.java | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+) 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/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); + } } From ba315b0b42face97fccd733bfdbb547b7e0d30f3 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 17:42:51 +0200 Subject: [PATCH 6/7] docs: document the options added for API 1.230 [COMP-2645] --fusion-version, the azure-cloud Key Vault and google-cloud billing table options, credentials federation-setup, and the new launch --wait exit code. Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 1edf7ecdcf703fec8e3a327968151af3d7ab16b9 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 17:42:53 +0200 Subject: [PATCH 7/7] build(native): register reflection metadata for the API 1.230 commands [COMP-2645] Entries for the federation-setup command and response, taken from a full test run under the GraalVM tracing agent. Response classes register allDeclaredFields, since Jackson serializes every field for -o json. Co-Authored-By: Claude Opus 5.5 (1M context) --- conf/reflect-config.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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,