From 250b6fd440e9069b6a134bc589a304f6d14a3155 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:15:21 +0200 Subject: [PATCH 01/28] feat(compute-envs): add CloudWatch log group and DRAGEN options to AWS Batch The Platform API (at least since 1.101.0) accepts AwsBatchConfig.logGroup and the ForgeConfig DRAGEN fields (dragenEnabled, dragenAmiId, dragenInstanceType), and the web UI exposes them, but tw never set them. - --log-group for 'aws-batch forge' and 'aws-batch manual' - --dragen, --dragen-ami-id, --dragen-instance-type for 'aws-batch forge' DRAGEN settings are checked client-side because the AMI and instance type are only validated during asynchronous forge creation, so a missing value would otherwise surface later as an errored compute environment. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platforms/AwsBatchForgePlatform.java | 32 +++++++++++- .../platforms/AwsBatchManualPlatform.java | 6 ++- .../platforms/AwsBatchForgePlatformTest.java | 50 +++++++++++++++++++ .../platforms/AwsBatchManualPlatformTest.java | 4 +- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java index 514b0cb40..a83b58c9a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java @@ -127,7 +127,8 @@ public AwsBatchConfig computeConfig() throws ApiException, IOException { .headJobCpus(adv().headJobCpus) .headJobMemoryMb(adv().headJobMemoryMb) .headJobRole(adv().headJobRole) - .secretsKmsKeyId(adv().secretsKmsKeyId); + .secretsKmsKeyId(adv().secretsKmsKeyId) + .logGroup(adv().logGroup); // Common config.workDir(workDir) @@ -152,6 +153,8 @@ private ForgeConfig buildForge() throws TowerException, IOException { throw new TowerException("Fusion v1 is deprecated, please use '--fusion-v2' instead"); } + validateDragen(); + ForgeConfig forge = new ForgeConfig() .type(provisioningModel) .maxCpus(maxCpus) @@ -171,7 +174,10 @@ private ForgeConfig buildForge() throws TowerException, IOException { .ebsBootSize(adv().bootDiskSizeGb) .bidPercentage(adv().bidPercentage) .fargateHeadEnabled(fargate) - .ecsConfig(FilesHelper.readString(ecsConfig)); + .ecsConfig(FilesHelper.readString(ecsConfig)) + .dragenEnabled(adv().dragen) + .dragenAmiId(adv().dragenAmiId) + .dragenInstanceType(adv().dragenInstanceType); if (efs != null) { @@ -191,6 +197,16 @@ private ForgeConfig buildForge() throws TowerException, IOException { return forge; } + private void validateDragen() throws TowerException { + boolean dragenSettings = adv().dragenAmiId != null || adv().dragenInstanceType != null; + if (!adv().dragen && dragenSettings) { + throw new TowerException("DRAGEN AMI ID and instance type require '--dragen'"); + } + if (adv().dragen && (adv().dragenAmiId == null || adv().dragenInstanceType == null)) { + throw new TowerException("DRAGEN requires '--dragen-ami-id' and '--dragen-instance-type'"); + } + } + private AdvancedOptions adv() { if (adv == null) { return new AdvancedOptions(); @@ -278,6 +294,18 @@ public static class AdvancedOptions { @Option(names = {"--secrets-kms-key"}, description = "Customer-managed KMS key used to encrypt the temporary Secrets Manager secrets created for runs that use pipeline secrets. Accepts a key ARN or a key id. When omitted, the AWS-managed default Secrets Manager key is used.") public String secretsKmsKeyId; + @Option(names = {"--log-group"}, description = "CloudWatch log group for the logs of pipeline executions. Must already exist. If absent, Platform defaults to /aws/batch/job.") + public String logGroup; + + @Option(names = {"--dragen"}, description = "Enable Illumina DRAGEN instances. Requires --dragen-ami-id and --dragen-instance-type. Not compatible with EFS, FSx or Fargate.") + public boolean dragen; + + @Option(names = {"--dragen-ami-id"}, description = "AMI ID for DRAGEN instances.") + public String dragenAmiId; + + @Option(names = {"--dragen-instance-type"}, description = "EC2 instance type for DRAGEN instances (e.g., f2.6xlarge, f2.12xlarge, f2.48xlarge).") + public String dragenInstanceType; + } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java index 7dfd73f2a..2f8d44080 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java @@ -91,7 +91,8 @@ public AwsBatchConfig computeConfig() throws IOException, ApiException { .headJobCpus(adv().headJobCpus) .headJobMemoryMb(adv().headJobMemoryMb) .headJobRole(adv().headJobRole) - .secretsKmsKeyId(adv().secretsKmsKeyId); + .secretsKmsKeyId(adv().secretsKmsKeyId) + .logGroup(adv().logGroup); // Common config.workDir(workDir) @@ -137,5 +138,8 @@ public static class AdvancedOptions { @Option(names = {"--secrets-kms-key"}, description = "Customer-managed KMS key used to encrypt the temporary Secrets Manager secrets created for runs that use pipeline secrets. Accepts a key ARN or a key id. When omitted, the AWS-managed default Secrets Manager key is used.") public String secretsKmsKeyId; + + @Option(names = {"--log-group"}, description = "CloudWatch log group for the logs of pipeline executions. Must already exist. If absent, Platform defaults to /aws/batch/job.") + public String logGroup; } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java index b5efa55eb..1b9b597bb 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java @@ -128,6 +128,56 @@ void testAddWithAdvanced(MockServerClient mock) { assertEquals(0, out.exitCode); } + @Test + void testAddWithDragenAndLogGroup(MockServerClient mock) { + + mock.when( + request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "aws-batch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentials\":[{\"id\":\"6g0ER59L4ZoE5zpOmUP48D\",\"name\":\"aws\",\"discriminator\":\"aws\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json(""" + {"computeEnv":{"name":"demo","platform":"aws-batch","credentialsId":"6g0ER59L4ZoE5zpOmUP48D","config":{ + "region":"eu-west-1","workDir":"s3://nextflow-ci/jordeu","logGroup":"/my/log-group", + "forge":{"type":"SPOT","maxCpus":123,"dragenEnabled":true,"dragenAmiId":"ami-0123456789abcdef0","dragenInstanceType":"f2.6xlarge"}}}} + """)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "forge", "-n", "demo", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--max-cpus=123", + "--log-group", "/my/log-group", "--dragen", "--dragen-ami-id", "ami-0123456789abcdef0", "--dragen-instance-type", "f2.6xlarge"); + + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("aws-batch", "isnEDBLvHDAIteOEF44ow", "demo", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithDragenMissingAmi(MockServerClient mock) { + + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "forge", "-n", "demo", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--max-cpus=123", + "--dragen", "--dragen-instance-type", "f2.6xlarge"); + + assertEquals(errorMessage(out.app, new TowerException("DRAGEN requires '--dragen-ami-id' and '--dragen-instance-type'")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWithDragenSettingsWithoutDragen(MockServerClient mock) { + + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "forge", "-n", "demo", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--max-cpus=123", + "--dragen-ami-id", "ami-0123456789abcdef0"); + + assertEquals(errorMessage(out.app, new TowerException("DRAGEN AMI ID and instance type require '--dragen'")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + @Test void testAddWithEnvVars(MockServerClient mock) { diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java index 33ad8d88b..ae65a0aeb 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java @@ -70,12 +70,12 @@ void testAddAdvanceOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs") - .withBody(JsonBody.json("{\"computeEnv\":{\"name\":\"manual\",\"platform\":\"aws-batch\",\"config\":{\"region\":\"eu-west-1\",\"fusion2Enabled\":true,\"waveEnabled\":true,\"nvnmeStorageEnabled\":true,\"computeQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-work\",\"executionRole\":\"execution-arn\",\"headQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-head\",\"cliPath\":\"/bin/aws\",\"workDir\":\"s3://nextflow-ci/jordeu\"},\"credentialsId\":\"6g0ER59L4ZoE5zpOmUP48D\"}}")), exactly(1) + .withBody(JsonBody.json("{\"computeEnv\":{\"name\":\"manual\",\"platform\":\"aws-batch\",\"config\":{\"region\":\"eu-west-1\",\"fusion2Enabled\":true,\"waveEnabled\":true,\"nvnmeStorageEnabled\":true,\"computeQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-work\",\"executionRole\":\"execution-arn\",\"headQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-head\",\"cliPath\":\"/bin/aws\",\"logGroup\":\"/my/log-group\",\"workDir\":\"s3://nextflow-ci/jordeu\"},\"credentialsId\":\"6g0ER59L4ZoE5zpOmUP48D\"}}")), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "manual", "-n", "manual", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--fusion-v2", "--wave", "--fast-storage", "--head-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-head", "--compute-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-work", "--cli-path=/bin/aws", "--batch-execution-role", "execution-arn"); + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "manual", "-n", "manual", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--fusion-v2", "--wave", "--fast-storage", "--head-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-head", "--compute-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-work", "--cli-path=/bin/aws", "--batch-execution-role", "execution-arn", "--log-group", "/my/log-group"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("aws-batch", "isnEDBLvHDAIteOEF44ow", "manual", null, USER_WORKSPACE_NAME).toString(), out.stdOut); From 94c62e182c9488c5cca2dccd4736f6b479100b45 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:15:32 +0200 Subject: [PATCH 02/28] feat(compute-envs): add --propagate-head-job-options to HPC platforms Slurm, LSF, Univa Grid Engine, Altair PBS and Moab configs have accepted propagateHeadJobOptions since at least API 1.101.0, and the web UI shows it as 'Apply head job submit options to compute jobs'. tw never set it, so CLI-created HPC environments could not pass the head job submit options to the Nextflow compute jobs (process.clusterOptions). The option requires --head-job-options: Platform reads the head job options unguarded when propagation is on, so the combination is rejected up front. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../computeenvs/platforms/AbstractPlatform.java | 8 ++++++++ .../computeenvs/platforms/AltairPlatform.java | 6 +++++- .../computeenvs/platforms/LsfPlatform.java | 6 +++++- .../computeenvs/platforms/MoabPlatform.java | 6 +++++- .../computeenvs/platforms/SlurmPlatform.java | 6 +++++- .../computeenvs/platforms/UnivaPlatform.java | 6 +++++- .../computeenvs/platforms/AltairPlatformTest.java | 4 ++-- .../cli/computeenvs/platforms/LsfPlatformTest.java | 4 ++-- .../computeenvs/platforms/MoabPlatformTest.java | 4 ++-- .../computeenvs/platforms/SlurmPlatformTest.java | 14 ++++++++++++-- .../computeenvs/platforms/UnivaPlatformTest.java | 4 ++-- 11 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java index 2843cf29f..2931284cf 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java @@ -18,6 +18,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.api.CredentialsApi; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.model.ComputeConfig; import io.seqera.tower.model.ComputeEnvComputeConfig.PlatformEnum; @@ -93,6 +94,13 @@ protected List environmentVariables() { return vars; } + protected static Boolean propagateHeadJobOptions(Boolean propagate, String headJobOptions) throws TowerException { + if (Boolean.TRUE.equals(propagate) && headJobOptions == null) { + throw new TowerException("'--propagate-head-job-options' requires '--head-job-options'"); + } + return propagate; + } + public PlatformEnum type() { return type; } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java index 17a2d4470..4fd6dbc64 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java @@ -69,7 +69,8 @@ public AltairPbsComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the submit command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java index fb0b5478c..a066099eb 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java @@ -73,7 +73,8 @@ public LsfComputeConfig computeConfig() throws ApiException, IOException { .perJobMemLimit(adv().perJobMemLimit) .perTaskReserve(adv().perTaskReserve) .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -99,6 +100,9 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the bsub command for the main orchestration process.") public String headJobOptions; + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; + @Option(names = {"--unit-for-limits"}, description = "Memory limit unit for LSF cluster. Must match LSF_UNIT_FOR_LIMITS in lsf.conf configuration file.") public String unitForLimits; diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java index 881d9bf95..5c2e5ce0e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java @@ -69,7 +69,8 @@ public MoabComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the submit command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java index 3412ea5c7..307df7bbd 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java @@ -69,7 +69,8 @@ public SlurmComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the sbatch command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java index 7ed1a1513..dc55d2a4a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java @@ -69,7 +69,8 @@ public UnivaComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the submit command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java index cd6a15d1d..34e61f1ed 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java @@ -72,13 +72,13 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"altair\",\"platform\":\"altair-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"altair\",\"platform\":\"altair-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "altair", "-n", "altair", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "altair", "-n", "altair", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("altair-platform", "isnEDBLvHDAIteOEF44ow", "altair", null, USER_WORKSPACE_NAME).toString(), out.stdOut); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java index 81bae41ef..f72657fba 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java @@ -70,14 +70,14 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"lsf\",\"platform\":\"lsf-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"lsf\",\"platform\":\"lsf-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "lsf", "-n", "lsf", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "lsf", "-n", "lsf", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("lsf-platform", "isnEDBLvHDAIteOEF44ow", "lsf", null, USER_WORKSPACE_NAME).toString(), out.stdOut); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java index 496f781cb..8b39eda3b 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java @@ -68,13 +68,13 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"moab\",\"platform\":\"moab-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"moab\",\"platform\":\"moab-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "moab", "-n", "moab", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "moab", "-n", "moab", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java index 2bc20f7d0..822d84a22 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java @@ -18,6 +18,7 @@ import io.seqera.tower.cli.BaseCmdTest; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvAdded; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -69,14 +70,14 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"slurm\",\"platform\":\"slurm-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"slurm\",\"platform\":\"slurm-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "slurm", "-n", "slurm", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "slurm", "-n", "slurm", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("slurm-platform", "isnEDBLvHDAIteOEF44ow", "slurm", null, USER_WORKSPACE_NAME).toString(), out.stdOut); @@ -84,4 +85,13 @@ void testAddWithAdvancedOptions(MockServerClient mock) { } + @Test + void testAddPropagateHeadJobOptionsRequiresHeadJobOptions(MockServerClient mock) { + + ExecOut out = exec(mock, "compute-envs", "add", "slurm", "-n", "slurm", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--propagate-head-job-options"); + + assertEquals(errorMessage(out.app, new TowerException("'--propagate-head-job-options' requires '--head-job-options'")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java index f8776ffd4..33ce15190 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java @@ -69,14 +69,14 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"uge\",\"platform\":\"uge-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"uge\",\"platform\":\"uge-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "uge", "-n", "uge", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "uge", "-n", "uge", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("uge-platform", "isnEDBLvHDAIteOEF44ow", "uge", null, USER_WORKSPACE_NAME).toString(), out.stdOut); From 894456627878bd133cb47965db2c44f3cb27c3b4 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:15:34 +0200 Subject: [PATCH 03/28] feat(compute-envs): add --data-retention-policy to Seqera Compute SeqeraComputeConfig.defaultDataRetentionPolicy (API 1.101.0 or earlier) asks Seqera Compute to put a lifecycle policy on the bucket it creates, deleting intermediate files after 28 days. The web UI shows it as 'Automatic Data Retention Policy' and turns it on by default; tw never sent it, so CLI-created environments always got a bucket without the policy. The flag is opt-in, which keeps the current tw behaviour when it is not given. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../computeenvs/platforms/SeqeraComputePlatform.java | 6 +++++- .../computeenvs/platforms/SeqeraComputePlatformTest.java | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java index 86f9f1e6f..75a3d6700 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java @@ -35,6 +35,9 @@ public class SeqeraComputePlatform extends AbstractPlatform @CommandLine.Option(names = {"--instance-type-size"}, description = "Studios instance size, controlling compute resources and capabilities. Options: ${COMPLETION-CANDIDATES}. Free-tier organizations are limited to SMALL.") public SeqeraComputeCloudInstanceTypeSize instanceTypeSize; + @CommandLine.Option(names = {"--data-retention-policy"}, description = "Apply the automatic data retention policy to the S3 bucket created by Seqera Compute. Intermediate files are deleted after 28 days to manage storage cost.") + public Boolean dataRetentionPolicy; + public SeqeraComputePlatform() { super(PlatformEnum.SEQERACOMPUTE_PLATFORM); } @@ -47,7 +50,8 @@ public SeqeraComputeConfig computeConfig() throws ApiException, IOException { // settings will automatically be configured by seqera compute and can't // be overridden. - config.region(region); + config.region(region) + .defaultDataRetentionPolicy(dataRetentionPolicy); if (instanceTypeSize != null) { config.instanceTypeSize(instanceTypeSize); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java index cbf854dcf..4c45173c5 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java @@ -91,6 +91,7 @@ void testAddWithOptions(MockServerClient mock) throws IOException { "platform": "seqeracompute-platform", "config": { "region": "eu-west-2", + "defaultDataRetentionPolicy": true, "preRunScript": "pre_run_me", "postRunScript": "post_run_me", "environment": [ @@ -115,6 +116,7 @@ void testAddWithOptions(MockServerClient mock) throws IOException { "-n", "another-compute-env", "-r", "eu-west-2", "--work-dir", "my-work-dir", + "--data-retention-policy", "--nextflow-config", tempFile("nextflow_config", "nextflow", "config"), "-e", "head:KEY1=value1", "-e", "both:KEY2=value2", "--pre-run", tempFile("pre_run_me", "pre", "sh"), From 3c5447c970120b127d05ab8b0bbab3c57e5995d3 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:16:01 +0200 Subject: [PATCH 04/28] feat(compute-envs): add log group and project ID cloud options Two cloud config fields accepted by the Platform API (at least since 1.101.0) were never set by tw: - aws-cloud --log-group (AwsCloudConfig.logGroup): CloudWatch Logs group for the CE; Platform defaults it to /seqera/platform. - google-cloud --project-id (GoogleCloudConfig.projectId): written as google.project and used to qualify short network/subnetwork names. Platform does not fill it from the credentials for Google Cloud. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../commands/computeenvs/platforms/AwsCloudPlatform.java | 6 +++++- .../commands/computeenvs/platforms/GoogleCloudPlatform.java | 6 +++++- .../cli/computeenvs/platforms/AwsCloudPlatformTest.java | 2 ++ .../cli/computeenvs/platforms/GoogleCloudPlatformTest.java | 2 ++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java index 86ca4a8bb..a67a8ae3d 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java @@ -103,7 +103,8 @@ public AwsCloudConfig computeConfig() throws ApiException, IOException { .vpcId(adv.vpcId) .subnetId(adv.subnetId) .subnetIds(adv.subnetIds) - .securityGroups(adv.securityGroups); + .securityGroups(adv.securityGroups) + .logGroup(adv.logGroup); } // Common @@ -146,6 +147,9 @@ public static class AdvancedOptions { @Option(names = {"--ec2-key-pair"}, description = "EC2 key pair name for SSH access to running instances. The key pair must already exist in the specified region.") public String ec2KeyPair; + @Option(names = {"--log-group"}, description = "CloudWatch Logs group where the compute environment logs are written. If absent, Platform uses /seqera/platform.") + public String logGroup; + @Option(names = {"--image-id"}, description = "AMI ID for launching EC2 instances. If omitted, Seqera-maintained default AMI is used. Use Seqera AMIs for best performance.") public String imageId; 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 43879b8a7..eeea3031f 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 @@ -100,7 +100,8 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { .network(adv.network) .subnetworks(adv.subnetworks) .networkTags(adv.networkTags) - .usePrivateAddress(adv.usePrivateAddress); + .usePrivateAddress(adv.usePrivateAddress) + .projectId(adv.projectId); } // Common @@ -168,6 +169,9 @@ public static class AdvancedOptions { @Option(names = {"--network"}, description = "Google Cloud VPC network name or URI. Required when using subnetworks or network tags. When omitted, the project's 'default' network is used.") public String network; + @Option(names = {"--project-id"}, description = "Google Cloud project ID. Set as google.project in the Nextflow configuration and used to qualify short --network and --subnetworks names.") + public String projectId; + @Option(names = {"--subnetworks"}, split = ",", paramLabel = "", description = "Google Cloud VPC subnetworks for instance placement. Comma-separated list of names or URIs in the same region as the compute environment; the first is used for basic placement while Intelligent Compute may use all of them. Requires --network.") public List subnetworks; diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java index 8345474d5..b35931e29 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java @@ -126,6 +126,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "workDir": "s3://my-bucket", "region": "eu-west-1", "instanceType": "t3.medium", + "logGroup": "/my/log-group", "imageId": "ami-12345678", "fusion2Enabled": true, "waveEnabled": true, @@ -157,6 +158,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "-r", "eu-west-1", "--allow-buckets", "s3://bucket1,s3://bucket2", "--instance-type", "t3.medium", + "--log-group", "/my/log-group", "--image-id", "ami-12345678", "--arm64", "--ec2-key-pair", "my-key-pair", 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 3f0ae14f1..05b347fc9 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 @@ -128,6 +128,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "region": "europe-west1", "zone": "europe-west1-b", "instanceType": "n2-standard-4", + "projectId": "my-project", "imageId": "projects/my-project/global/images/my-image", "fusion2Enabled": true, "waveEnabled": true, @@ -152,6 +153,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "-r", "europe-west1", "-z", "europe-west1-b", "--instance-type", "n2-standard-4", + "--project-id", "my-project", "--image-id", "projects/my-project/global/images/my-image", "--arm64", "--boot-disk-size", "100" From d02880dcd1f03d2eb48bc7f73e0061961006343a Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:16:03 +0200 Subject: [PATCH 05/28] feat(compute-envs): add enable and disable commands The API has long exposed POST /compute-envs/{id}/enable and /disable (EnableComputeEnv, DisableComputeEnv), but tw offered no way to call them, so a disabled compute environment could only be re-enabled from the UI. Both commands take the usual -i/-n and -w references, like compute-envs delete. The endpoints return 204, so the output only confirms the action. Platform rejects invalid transitions (e.g. enabling an AVAILABLE CE) with a 400 whose message tw prints as is; disabling a primary CE also unsets it as primary. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/ComputeEnvsCmd.java | 4 + .../cli/commands/computeenvs/DisableCmd.java | 48 +++++++++++ .../cli/commands/computeenvs/EnableCmd.java | 48 +++++++++++ .../computeenvs/ComputeEnvDisabled.java | 35 ++++++++ .../computeenvs/ComputeEnvEnabled.java | 35 ++++++++ .../cli/computeenvs/ComputeEnvsCmdTest.java | 83 +++++++++++++++++++ 6 files changed, 253 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java diff --git a/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java b/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java index 53cd2ce62..e775dafd3 100644 --- a/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.cli.commands.computeenvs.AddCmd; import io.seqera.tower.cli.commands.computeenvs.DeleteCmd; +import io.seqera.tower.cli.commands.computeenvs.DisableCmd; +import io.seqera.tower.cli.commands.computeenvs.EnableCmd; import io.seqera.tower.cli.commands.computeenvs.ExportCmd; import io.seqera.tower.cli.commands.computeenvs.ImportCmd; import io.seqera.tower.cli.commands.computeenvs.ListCmd; @@ -41,6 +43,8 @@ ImportCmd.class, PrimaryCmd.class, ValidateCmd.class, + EnableCmd.class, + DisableCmd.class, } ) public class ComputeEnvsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java new file mode 100644 index 000000000..12c5ebf5c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java @@ -0,0 +1,48 @@ +/* + * 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.computeenvs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.computeenvs.ComputeEnvDisabled; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "disable", + description = "Disable a compute environment so it cannot be used to launch workflows. A primary compute environment is unset as primary." +) +public class DisableCmd extends AbstractComputeEnvCmd { + + @CommandLine.Mixin + public ComputeEnvRefOptions computeEnvRefOptions; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String id = computeEnvRefOptions.computeEnv.computeEnvId != null + ? computeEnvRefOptions.computeEnv.computeEnvId + : computeEnvByName(wspId, computeEnvRefOptions.computeEnv.computeEnvName).getId(); + + computeEnvsApi().disableComputeEnv(id, wspId, null); + return new ComputeEnvDisabled(id, workspaceRef(wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java new file mode 100644 index 000000000..ba08bcb13 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java @@ -0,0 +1,48 @@ +/* + * 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.computeenvs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.computeenvs.ComputeEnvEnabled; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "enable", + description = "Enable a disabled compute environment so it can be used to launch workflows." +) +public class EnableCmd extends AbstractComputeEnvCmd { + + @CommandLine.Mixin + public ComputeEnvRefOptions computeEnvRefOptions; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String id = computeEnvRefOptions.computeEnv.computeEnvId != null + ? computeEnvRefOptions.computeEnv.computeEnvId + : computeEnvByName(wspId, computeEnvRefOptions.computeEnv.computeEnvName).getId(); + + computeEnvsApi().enableComputeEnv(id, wspId, null); + return new ComputeEnvEnabled(id, workspaceRef(wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java new file mode 100644 index 000000000..87e8bf3a7 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java @@ -0,0 +1,35 @@ +/* + * 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.computeenvs; + +import io.seqera.tower.cli.responses.Response; + +public class ComputeEnvDisabled extends Response { + + public final String id; + public final String workspaceRef; + + public ComputeEnvDisabled(String id, String workspaceRef) { + this.id = id; + this.workspaceRef = workspaceRef; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Compute environment '%s' disabled at %s workspace|@%n", id, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java new file mode 100644 index 000000000..7bfaf7404 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java @@ -0,0 +1,35 @@ +/* + * 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.computeenvs; + +import io.seqera.tower.cli.responses.Response; + +public class ComputeEnvEnabled extends Response { + + public final String id; + public final String workspaceRef; + + public ComputeEnvEnabled(String id, String workspaceRef) { + this.id = id; + this.workspaceRef = workspaceRef; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Compute environment '%s' enabled at %s workspace|@%n", id, workspaceRef)); + } +} diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java index a5bd7af8a..acc924165 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java @@ -29,6 +29,8 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvAdded; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvDeleted; +import io.seqera.tower.cli.responses.computeenvs.ComputeEnvDisabled; +import io.seqera.tower.cli.responses.computeenvs.ComputeEnvEnabled; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvExport; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvList; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvUpdated; @@ -808,4 +810,85 @@ void testValidateInvalid(MockServerClient mock) { assertEquals(1, out.exitCode); assertEquals(new ComputeEnvValidated("isnEDBLvHDAIteOEF44ow", "demo", USER_WORKSPACE_NAME, ComputeEnvStatus.INVALID, false, "Work directory not accessible").toString(), out.stdOut); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testEnable(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2/enable"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "compute-envs", "enable", "-i", "vYOK4vn7spw7bHHWBDXZ2"); + assertOutput(format, out, new ComputeEnvEnabled("vYOK4vn7spw7bHHWBDXZ2", USER_WORKSPACE_NAME)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisableByName(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/compute-envs"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_envs_list")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/3xkkzYH2nbD3nZjrzKm0oR"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnv\":{\"id\":\"3xkkzYH2nbD3nZjrzKm0oR\",\"name\":\"ce1\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/compute-envs/3xkkzYH2nbD3nZjrzKm0oR/disable"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "compute-envs", "disable", "-n", "ce1"); + assertOutput(format, out, new ComputeEnvDisabled("3xkkzYH2nbD3nZjrzKm0oR", USER_WORKSPACE_NAME)); + } + + @Test + void testDisableInWorkspace(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2/disable") + .withQueryStringParameter("workspaceId", "75887156211590"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/user/1264/workspaces"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "disable", "-i", "vYOK4vn7spw7bHHWBDXZ2", "-w", "75887156211590"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new ComputeEnvDisabled("vYOK4vn7spw7bHHWBDXZ2", "[organization2 / workspace2]").toString(), out.stdOut); + } + + @Test + void testEnableRejected(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2/enable"), exactly(1) + ).respond( + response().withStatusCode(400).withBody("{\"message\":\"Compute environment cannot be enabled - current status: AVAILABLE\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "enable", "-i", "vYOK4vn7spw7bHHWBDXZ2"); + + assertEquals(errorMessage(out.app, new ApiException(400, "", null, "{\"message\":\"Compute environment cannot be enabled - current status: AVAILABLE\"}")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } } From 6fa91df5dc237498cbd8ea4ace76c84e8cf830de Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:17:22 +0200 Subject: [PATCH 06/28] feat(launch): add --tower-config option WorkflowLaunchRequest has carried towerConfig since at least API 1.101.0, but it could not be set from the CLI. It is the per-run Seqera configuration in tower.yml format, overriding the pipeline's tower.yml (the "Seqera Cloud config file" field of the launch form; Platform caps it at 3500 characters). --tower-config is a plain string, so it goes through the same coalesce chains as --config in launch, pipelines add|update, actions add|update and runs relaunch. Actions are wired too, because LaunchOptions exposes the option there and leaving it unapplied would silently drop it. The 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) --- .../seqera/tower/cli/commands/LaunchCmd.java | 4 ++ .../tower/cli/commands/actions/UpdateCmd.java | 1 + .../commands/actions/add/AbstractAddCmd.java | 1 + .../tower/cli/commands/pipelines/AddCmd.java | 1 + .../cli/commands/pipelines/LaunchOptions.java | 3 ++ .../cli/commands/pipelines/UpdateCmd.java | 1 + .../tower/cli/commands/runs/RelaunchCmd.java | 1 + .../io/seqera/tower/cli/LaunchCmdTest.java | 4 +- .../tower/cli/actions/ActionsCmdTest.java | 30 +++++++++++++++ .../tower/cli/pipelines/PipelinesCmdTest.java | 9 +++-- .../io/seqera/tower/cli/runs/RunsCmdTest.java | 38 +++++++++++++++++++ .../runcmd/runs/workflow_launch_v2.json | 2 +- 12 files changed, 90 insertions(+), 5 deletions(-) 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 4dbfa1a3f..6b3252b4a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java @@ -156,6 +156,7 @@ private WorkflowLaunchRequest updateLaunchRequest(WorkflowLaunchRequest base) th .userSecrets(coalesce(removeEmptyValues(adv().userSecrets), base.getUserSecrets())) .workspaceSecrets(coalesce(removeEmptyValues(adv().workspaceSecrets), base.getWorkspaceSecrets())) .configText(coalesce(readStringOrStdin(adv().config), base.getConfigText())) + .towerConfig(coalesce(readStringOrStdin(adv().towerConfig), base.getTowerConfig())) .paramsText(coalesce(readStringOrStdin(paramsFile), base.getParamsText())) .preRunScript(coalesce(readStringOrStdin(adv().preRunScript), base.getPreRunScript())) .postRunScript(coalesce(readStringOrStdin(adv().postRunScript), base.getPostRunScript())) @@ -283,6 +284,9 @@ public static class AdvancedOptions { @Option(names = {"--config"}, description = "Nextflow configuration as text (overrides config files). Provide the path to a file containing the content. Use '-' to read from stdin.") public Path config; + @Option(names = {"--tower-config"}, description = "Seqera Platform configuration in tower.yml format. Overrides the pipeline's tower.yml for this run. Provide the path to a file containing the content. Use '-' to read from stdin.") + public Path towerConfig; + @Option(names = {"--pre-run"}, description = "Add a script that executes in the nf-launch script prior to invoking Nextflow processes. See: https://docs.seqera.io/platform-cloud/launch/advanced#pre-and-post-run-scripts. Provide the path to a file containing the content. Use '-' to read from stdin.") public Path preRunScript; diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java index 0241bd413..066011a6f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java @@ -92,6 +92,7 @@ protected Response exec() throws ApiException, IOException { // Advanced options .configText(coalesce(FilesHelper.readString(opts.config), action.getLaunch().getConfigText())) + .towerConfig(coalesce(FilesHelper.readString(opts.towerConfig), action.getLaunch().getTowerConfig())) .preRunScript(preRunScriptValue) .postRunScript(postRunScriptValue) .pullLatest(coalesce(opts.pullLatest, action.getLaunch().getPullLatest())) diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java index b21b8239e..553c83155 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java @@ -81,6 +81,7 @@ protected Response exec() throws ApiException, IOException { // Advanced options .configText(FilesHelper.readString(opts.config)) + .towerConfig(FilesHelper.readString(opts.towerConfig)) .preRunScript(preRunScriptValue) .postRunScript(postRunScriptValue) .pullLatest(opts.pullLatest) diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java index d57b62152..ac2d5321d 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java @@ -108,6 +108,7 @@ protected Response exec() throws ApiException, IOException { // Advanced options .configText(FilesHelper.readString(opts.config)) + .towerConfig(FilesHelper.readString(opts.towerConfig)) .preRunScript(preRunScriptValue) .postRunScript(postRunScriptValue) .pullLatest(opts.pullLatest) diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java index fb66f05f4..b13348ed1 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java @@ -44,6 +44,9 @@ public class LaunchOptions extends NullableLaunchOptions { @Option(names = {"--config"}, description = "Nextflow configuration as text (overrides config files). Provide the path to a file containing the content.") public Path config; + @Option(names = {"--tower-config"}, description = "Seqera Platform configuration in tower.yml format. Overrides the pipeline's tower.yml for this run. Provide the path to a file containing the content.") + public Path towerConfig; + @Option(names = {"--pre-run"}, description = "Add a script that executes in the nf-launch script prior to invoking Nextflow processes. See: https://docs.seqera.io/platform-cloud/launch/advanced#pre-and-post-run-scripts. Provide the path to a file containing the content.") public Path preRunScript; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java index edb22f3af..073ef725a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java @@ -212,6 +212,7 @@ private UpdatePipelineRequest buildUpdateRequest(PipelineDbDto pipe, LaunchDbDto .configProfiles(coalesce(opts.profile, launch.getConfigProfiles())) .paramsText(coalesce(FilesHelper.readString(opts.paramsFile), launch.getParamsText())) .configText(coalesce(FilesHelper.readString(opts.config), launch.getConfigText())) + .towerConfig(coalesce(FilesHelper.readString(opts.towerConfig), launch.getTowerConfig())) .preRunScript(coalesce(FilesHelper.readString(opts.preRunScript), launch.getPreRunScript())) .postRunScript(coalesce(FilesHelper.readString(opts.postRunScript), launch.getPostRunScript())) .pullLatest(coalesce(opts.pullLatest, launch.getPullLatest())) diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java index 6dc8a7e73..ea0131ed9 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java @@ -101,6 +101,7 @@ protected Response exec() throws ApiException, IOException { .revision(coalesce(opts.revision, (noResume ? launch.getRevision() : launch.getResumeCommitId()))) .configProfiles(coalesce(opts.profile, launch.getConfigProfiles())) .configText(opts.config != null ? FilesHelper.readString(opts.config) : launch.getConfigText()) + .towerConfig(opts.towerConfig != null ? FilesHelper.readString(opts.towerConfig) : launch.getTowerConfig()) .paramsText(opts.paramsFile != null ? FilesHelper.readString(opts.paramsFile) : launch.getParamsText()) .preRunScript(opts.preRunScript != null ? FilesHelper.readString(opts.preRunScript) : launch.getPreRunScript()) .postRunScript(opts.postRunScript != null ? FilesHelper.readString(opts.postRunScript) : launch.getPostRunScript()) diff --git a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java index 11dfa2884..2cbf84a59 100644 --- a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java @@ -301,6 +301,7 @@ void testSubmitLaunchpadPipelineWithAdvancedOptions(MockServerClient mock) throw "revision":"develop", "configProfiles":["test","docker"], "configText":"extra_config", + "towerConfig":"reports: {}", "preRunScript":"pre_run_me", "postRunScript":"post_run_me", "mainScript":"alternate.nf", @@ -323,7 +324,8 @@ void testSubmitLaunchpadPipelineWithAdvancedOptions(MockServerClient mock) throw // Run the command ExecOut out = exec(mock, "launch", "sarek", "-p", "test,docker", "-r", "develop", "--work-dir", "/my_work_dir", - "--config", tempFile("extra_config", "nextflow", "config"), "--pull-latest", "--stub-run", + "--config", tempFile("extra_config", "nextflow", "config"), "--tower-config", tempFile("reports: {}", "tower", "yml"), + "--pull-latest", "--stub-run", "--pre-run", tempFile("pre_run_me", "pre", "sh"), "--post-run", tempFile("post_run_me", "post", "sh"), "--main-script", "alternate.nf", "--entry-name", "dsl2", "--schema-name", "my_schema.json", "--disable-optimization"); 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 e5c124495..a86a8d111 100644 --- a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java @@ -374,6 +374,36 @@ void testAdd(OutputType format, MockServerClient mock) { assertOutput(format, out, new ActionAdd("new-action", USER_WORKSPACE_NAME, "2Z1g6MCWpOLgHLA65cw1qt")); } + @Test + void testAddWithTowerConfig(MockServerClient mock) throws IOException { + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"primary\":true}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_env_demo")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/actions") + .withBody(json("{\"launch\":{\"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")); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + @ParameterizedTest @EnumSource(OutputType.class) void testAddWithOverwrite(OutputType format, MockServerClient mock) { 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 92595831e..6214bedf6 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -1185,7 +1185,7 @@ void testAddWithSyntaxParser(MockServerClient mock) { } @Test - void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) { + void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) throws IOException { mock.reset(); @@ -1208,6 +1208,7 @@ void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) { "launch":{ "pipeline":"https://github.com/pditommaso/nf-sleep", "nextflowVersion":"26.04.6", + "towerConfig":"reports: {}", "outputDir":"s3://nextflow-ci/outputs" } }""" @@ -1218,6 +1219,7 @@ void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) { ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "--nextflow-version", "26.04.6", "--output-dir", "s3://nextflow-ci/outputs", + "--tower-config", tempFile("reports: {}", "tower", "yml"), "https://github.com/pditommaso/nf-sleep"); assertEquals("", out.stdErr); @@ -1267,7 +1269,7 @@ void testUpdateKeepsStoredSyntaxParser(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\"}}") + .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: {}\"}}") ); mock.when( @@ -1279,7 +1281,8 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { "pipeline":"https://github.com/pditommaso/nf-sleep", "revision":"main", "syntaxParser":"v2", - "nextflowVersion":"26.04.6" + "nextflowVersion":"26.04.6", + "towerConfig":"reports: {}" } }""")), exactly(1) ).respond( 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 d4288ddd6..bb494be7d 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -573,6 +573,7 @@ void testRelaunchKeepsOriginalLaunchConfiguration(MockServerClient mock) { "pipelineSchemaId":42, "resume":true, "nextflowVersion":"26.04.6", + "towerConfig":"reports: {}", "outputDir":"/outputs", "syntaxParser":"v2" } @@ -618,6 +619,43 @@ void testRelaunchOverridingSyntaxParser(MockServerClient mock) { .withBody(json("{\"launch\":{\"syntaxParser\":\"v1\"}}")), VerificationTimes.exactly(1)); } + @Test + void testRelaunchOverridingTowerConfig(MockServerClient mock) throws IOException { + mock.reset(); + + mock.when( + request().withMethod("POST").withPath("/workflow/launch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workflow_launch")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/workflow/5mDfiUtqyptDib"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workflow_view")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/workflow/5mDfiUtqyptDib/launch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("runs/workflow_launch_v2")).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) + ); + + ExecOut out = exec(mock, "runs", "relaunch", "-i", "5mDfiUtqyptDib", + "--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)); + } + @Test void testInvalidAuth(MockServerClient mock) { mock.when( diff --git a/src/test/resources/runcmd/runs/workflow_launch_v2.json b/src/test/resources/runcmd/runs/workflow_launch_v2.json index caac10040..65bd06705 100644 --- a/src/test/resources/runcmd/runs/workflow_launch_v2.json +++ b/src/test/resources/runcmd/runs/workflow_launch_v2.json @@ -14,7 +14,7 @@ "sessionId": "d77a5ac4-1a25-45d6-bab0-358605abc332", "configProfiles": null, "configText": null, - "towerConfig": null, + "towerConfig": "reports: {}", "paramsText": null, "preRunScript": null, "postRunScript": null, From aeeb056c2452c843000891fd2a64c46f5fcec377 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:17:25 +0200 Subject: [PATCH 07/28] feat(runs): add 'tw runs star' and 'tw runs unstar' POST/DELETE /workflow/{workflowId}/star have been in the API since at least 1.101.0, but runs could only be starred from the web UI. Starring is per user, so a starred run surfaces in that user's run list in the UI. Unstarring a run that is not starred is a 404 on Platform; it is reported like any other API error rather than reinterpreted. The starred state is not added to 'tw runs view': it would cost an extra request, and the SDK types GET /workflow/{id}/star as CreateWorkflowStarResponse (workflowId only) although Platform returns DescribeWorkflowStarResponse with a 'starred' flag, so the flag cannot be read through the SDK. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../io/seqera/tower/cli/commands/RunsCmd.java | 6 ++- .../tower/cli/commands/runs/StarCmd.java | 45 +++++++++++++++++++ .../tower/cli/commands/runs/UnstarCmd.java | 45 +++++++++++++++++++ .../tower/cli/responses/runs/RunStarred.java | 40 +++++++++++++++++ .../io/seqera/tower/cli/runs/RunsCmdTest.java | 41 +++++++++++++++++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java diff --git a/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java b/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java index ade2bcc87..0725e6287 100644 --- a/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java @@ -22,6 +22,8 @@ import io.seqera.tower.cli.commands.runs.DumpCmd; import io.seqera.tower.cli.commands.runs.ListCmd; import io.seqera.tower.cli.commands.runs.RelaunchCmd; +import io.seqera.tower.cli.commands.runs.StarCmd; +import io.seqera.tower.cli.commands.runs.UnstarCmd; import io.seqera.tower.cli.commands.runs.ViewCmd; import picocli.CommandLine; @@ -35,7 +37,9 @@ CancelCmd.class, LabelsCmd.class, DeleteCmd.class, - DumpCmd.class + DumpCmd.class, + StarCmd.class, + UnstarCmd.class } ) public class RunsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java new file mode 100644 index 000000000..d4a4cdbb8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.runs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.runs.RunStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "star", + description = "Star a pipeline run" +) +public class StarCmd extends AbstractRunsCmd { + + @CommandLine.Option(names = {"-i", "--id"}, description = "Pipeline run identifier. The unique workflow ID to add to your starred runs.", required = true) + public String id; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + + workflowsApi().createWorkflowStar(id, wspId); + return new RunStarred(id, workspaceRef(wspId), true); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java new file mode 100644 index 000000000..7f70ab304 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.runs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.runs.RunStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unstar", + description = "Unstar a pipeline run" +) +public class UnstarCmd extends AbstractRunsCmd { + + @CommandLine.Option(names = {"-i", "--id"}, description = "Pipeline run identifier. The unique workflow ID to remove from your starred runs.", required = true) + public String id; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + + workflowsApi().deleteWorkflowStar(id, wspId); + return new RunStarred(id, workspaceRef(wspId), false); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java new file mode 100644 index 000000000..da3dc87e6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.runs; + +import io.seqera.tower.cli.responses.Response; + +public class RunStarred extends Response { + + public final String id; + public final String workspaceRef; + public final boolean starred; + + public RunStarred(String id, String workspaceRef, boolean starred) { + this.id = id; + this.workspaceRef = workspaceRef; + this.starred = starred; + } + + @Override + public String toString() { + return starred + ? ansi(String.format("%n @|yellow Pipeline run '%s' starred at %s workspace|@%n", id, workspaceRef)) + : ansi(String.format("%n @|yellow Pipeline run '%s' unstarred at %s workspace|@%n", id, workspaceRef)); + } +} 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 bb494be7d..43000f3fe 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -28,6 +28,7 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.runs.RunCanceled; import io.seqera.tower.cli.responses.runs.RunDeleted; +import io.seqera.tower.cli.responses.runs.RunStarred; import io.seqera.tower.cli.responses.runs.RunDump; import io.seqera.tower.cli.responses.runs.RunFileDownloaded; import io.seqera.tower.cli.responses.runs.RunList; @@ -123,6 +124,46 @@ void testCancel(OutputType format, MockServerClient mock) { assertOutput(format, out, new RunCanceled("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME)); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testStar(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"workflowId\":\"5dAZoXrcmZXRO4\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "runs", "star", "-i", "5dAZoXrcmZXRO4"); + assertOutput(format, out, new RunStarred("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME, true)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUnstar(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + assertOutput(format, out, new RunStarred("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME, false)); + } + + @Test + void testUnstarNotStarred(MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(404).withBody("{\"message\":\"Workflow '5dAZoXrcmZXRO4' is not starred\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + + assertEquals(errorMessage(out.app, new ApiException(404, "", null, "{\"message\":\"Workflow '5dAZoXrcmZXRO4' is not starred\"}")), out.stdErr); + assertEquals(1, out.exitCode); + } + @Test void testCancelForbidden(MockServerClient mock) { mock.when( From 519131b7a92b0c2c59fe97872715617a80631514 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:17:27 +0200 Subject: [PATCH 08/28] feat(runs): delete several runs in one 'tw runs delete' POST /workflow/delete (deleteWorkflowMany, in the API since at least 1.101.0) deletes a list of runs and reports the ones it could not delete, but tw only took one id, so cleaning up runs meant one invocation per run. '-i' now accepts several ids, repeated or comma-separated. A single id keeps calling DELETE /workflow/{id}, so its output and errors are unchanged. Several ids go through the bulk endpoint with --force passed on; each run is reported as deleted or not, and the command exits 1 when any could not be deleted so scripts notice partial failures. Platform caps the list size and rejects longer ones with a 400. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/runs/DeleteCmd.java | 26 +++++++--- .../tower/cli/responses/runs/RunsDeleted.java | 49 +++++++++++++++++++ .../io/seqera/tower/cli/runs/RunsCmdTest.java | 33 +++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java index b89305ec4..1547f1d8e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java @@ -14,24 +14,29 @@ * limitations under the License. */ + package io.seqera.tower.cli.commands.runs; import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.runs.RunDeleted; +import io.seqera.tower.cli.responses.runs.RunsDeleted; +import io.seqera.tower.model.DeleteWorkflowsRequest; import picocli.CommandLine; import java.io.IOException; +import java.util.List; +import java.util.Objects; @CommandLine.Command( name = "delete", - description = "Delete a pipeline run" + description = "Delete one or more pipeline runs" ) public class DeleteCmd extends AbstractRunsCmd { - @CommandLine.Option(names = {"-i", "-id"}, description = "Pipeline run identifier. The unique workflow ID to delete. Deletes the run record and associated metadata from Seqera Platform.", required = true) - public String id; + @CommandLine.Option(names = {"-i", "-id"}, split = ",", description = "Pipeline run identifier. The unique workflow ID to delete. Deletes the run record and associated metadata from Seqera Platform. Repeat the option or provide a comma-separated list to delete several runs at once.", required = true) + public List ids; @CommandLine.Option(names = {"--force"}, description = "Force deletion of active workflows. By default, only completed workflows can be deleted. Use this flag to delete running or pending workflows.") public boolean force = false; @@ -42,8 +47,17 @@ public class DeleteCmd extends AbstractRunsCmd { @Override protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); - - workflowsApi().deleteWorkflow(id, wspId, force); - return new RunDeleted(id, workspaceRef(wspId)); + + if (ids.size() == 1) { + workflowsApi().deleteWorkflow(ids.getFirst(), wspId, force); + return new RunDeleted(ids.getFirst(), workspaceRef(wspId)); + } + + // The bulk endpoint reports the runs it could not delete instead of failing the whole request. + List failed = Objects.requireNonNullElse(workflowsApi() + .deleteWorkflowMany(new DeleteWorkflowsRequest().workflowIds(ids), wspId, force) + .getFailedWorkflowIds(), List.of()); + List deleted = ids.stream().filter(id -> !failed.contains(id)).toList(); + return new RunsDeleted(deleted, failed, workspaceRef(wspId)); } } diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java new file mode 100644 index 000000000..498d91438 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java @@ -0,0 +1,49 @@ +/* + * 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.runs; + +import io.seqera.tower.cli.responses.Response; +import picocli.CommandLine; + +import java.io.PrintWriter; +import java.util.List; + +public class RunsDeleted extends Response { + + public final List deleted; + public final List failed; + public final String workspaceRef; + + public RunsDeleted(List deleted, List failed, String workspaceRef) { + this.deleted = deleted; + this.failed = failed; + this.workspaceRef = workspaceRef; + } + + @Override + public void toString(PrintWriter out) { + out.println(); + deleted.forEach(id -> out.println(ansi(String.format(" @|yellow Pipeline run '%s' deleted at %s workspace|@", id, workspaceRef)))); + failed.forEach(id -> out.println(ansi(String.format(" @|bold,red Pipeline run '%s' could not be deleted at %s workspace|@", id, workspaceRef)))); + } + + @Override + public int getExitCode() { + return failed.isEmpty() ? CommandLine.ExitCode.OK : CommandLine.ExitCode.SOFTWARE; + } +} 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 43000f3fe..2cedf887c 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -29,6 +29,7 @@ import io.seqera.tower.cli.responses.runs.RunCanceled; import io.seqera.tower.cli.responses.runs.RunDeleted; import io.seqera.tower.cli.responses.runs.RunStarred; +import io.seqera.tower.cli.responses.runs.RunsDeleted; import io.seqera.tower.cli.responses.runs.RunDump; import io.seqera.tower.cli.responses.runs.RunFileDownloaded; import io.seqera.tower.cli.responses.runs.RunList; @@ -57,6 +58,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.mockserver.client.MockServerClient; +import org.mockserver.matchers.MatchType; import org.mockserver.model.MediaType; import org.mockserver.verify.VerificationTimes; @@ -96,6 +98,37 @@ void testDelete(OutputType format, MockServerClient mock) { assertOutput(format, out, new RunDeleted("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME)); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteMany(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/delete") + .withQueryStringParameter("force", "true") + .withBody(json("{\"workflowIds\":[\"5dAZoXrcmZXRO4\",\"3xFx5yTHcIWQl\",\"1Gm7JBQAcU6pVO\"]}", MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"failedWorkflowIds\":[]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "runs", "delete", "-i", "5dAZoXrcmZXRO4,3xFx5yTHcIWQl", "-i", "1Gm7JBQAcU6pVO", "--force"); + assertOutput(format, out, new RunsDeleted(List.of("5dAZoXrcmZXRO4", "3xFx5yTHcIWQl", "1Gm7JBQAcU6pVO"), List.of(), USER_WORKSPACE_NAME)); + } + + @Test + void testDeleteManyPartialFailure(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/delete"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"failedWorkflowIds\":[\"3xFx5yTHcIWQl\"]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "delete", "-i", "5dAZoXrcmZXRO4,3xFx5yTHcIWQl"); + + assertEquals("", out.stdErr); + assertEquals(chop(new RunsDeleted(List.of("5dAZoXrcmZXRO4"), List.of("3xFx5yTHcIWQl"), USER_WORKSPACE_NAME).toString()), out.stdOut); + assertTrue(out.stdOut.contains("'3xFx5yTHcIWQl' could not be deleted"), out.stdOut); + assertEquals(1, out.exitCode); + } + @Test void testDeleteForbidden(MockServerClient mock) { mock.when( From 0ecb31d175403bbceee4f8365ce10cec448bddf2 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:18:15 +0200 Subject: [PATCH 09/28] feat(runs): add 'tw runs view log' to print a run or task execution log 'tw runs view download' and 'tw runs dump' fetch log files through /workflow/{id}/download, which serves the files the run leaves in its work directory. GET /workflow/{id}/log and /log/{taskId} (in the API since at least 1.101.0) are what the UI's Execution log tab uses: Platform reads the compute platform's live log stream when the provider has one and only falls back to the output file otherwise, so they also show the output of a run that is still going. tw had no way to reach them. 'tw runs view -i log' prints the Nextflow head job output and '-t ' the task output. Platform's own notice (for example why the log could not be read) is shown after the entries, and a truncated page says so and points to 'download' for the complete file. Only the first page is fetched. Following the cursor would turn the command into a 'tail -f', and how the cursor behaves differs per compute platform, so that is left for when someone needs it. Up to SDK 1.200.0 the spec types LogPage.entries as an Iterator object, so the SDK exposes it as Object; Platform sends a JSON array of strings, which LogCmd reads back as a list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/runs/ViewCmd.java | 2 + .../tower/cli/commands/runs/log/LogCmd.java | 60 +++++++++++++++++++ .../tower/cli/responses/runs/RunLog.java | 49 +++++++++++++++ .../io/seqera/tower/cli/runs/RunsCmdTest.java | 33 ++++++++++ 4 files changed, 144 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java index 0f87553ed..767b1f4da 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; import io.seqera.tower.cli.commands.runs.download.DownloadCmd; +import io.seqera.tower.cli.commands.runs.log.LogCmd; import io.seqera.tower.cli.commands.runs.metrics.MetricsCmd; import io.seqera.tower.cli.commands.runs.tasks.TaskCmd; import io.seqera.tower.cli.commands.runs.tasks.TasksCmd; @@ -48,6 +49,7 @@ description = "View pipeline run details", subcommands = { DownloadCmd.class, + LogCmd.class, MetricsCmd.class, TasksCmd.class, TaskCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java new file mode 100644 index 000000000..4b18a14c0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.runs.log; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.runs.AbstractRunsCmd; +import io.seqera.tower.cli.commands.runs.ViewCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.runs.RunLog; +import io.seqera.tower.model.LogPage; +import picocli.CommandLine; + +import java.util.List; + +@CommandLine.Command( + name = "log", + description = "Display the execution log of a pipeline run or task" +) +public class LogCmd extends AbstractRunsCmd { + + @CommandLine.Option(names = {"-t"}, description = "Task numeric identifier. When specified, displays the task output log instead of the Nextflow head job output.") + public Long task; + + @CommandLine.ParentCommand + ViewCmd parentCommand; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(parentCommand.workspace.workspace); + + // Platform serves the compute platform's live log stream when it has one, so unlike 'download' this + // also works while the run is active; otherwise it falls back to the output file. + LogPage page = task == null + ? workflowsApi().getWorkflowLog(parentCommand.id, wspId, null, null).getLog() + : workflowsApi().getWorkflowTaskLog(parentCommand.id, task, null, wspId, null).getLog(); + + return new RunLog(entries(page), Boolean.TRUE.equals(page.getTruncated()), page.getMessage()); + } + + // The API spec types LogPage.entries as an Iterator object, so the SDK exposes it as Object, but Platform sends a JSON + // array of strings, which Jackson deserializes as a List + private static List entries(LogPage page) { + return page.getEntries() instanceof List list ? list.stream().map(String::valueOf).toList() : null; + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java new file mode 100644 index 000000000..82d75bdbc --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java @@ -0,0 +1,49 @@ +/* + * 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.runs; + +import io.seqera.tower.cli.responses.Response; + +import java.io.PrintWriter; +import java.util.List; + +public class RunLog extends Response { + + public final List entries; + public final boolean truncated; + public final String message; + + public RunLog(List entries, boolean truncated, String message) { + this.entries = entries; + this.truncated = truncated; + this.message = message; + } + + @Override + public void toString(PrintWriter out) { + if (entries != null) { + entries.forEach(out::println); + } + if (message != null) { + out.println(ansi(String.format("%n @|yellow %s|@", message))); + } + if (truncated) { + out.println(ansi(String.format("%n @|yellow Log truncated. Use 'tw runs view download' to get the complete file once the run has finished.|@"))); + } + } +} 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 2cedf887c..796fa140a 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -28,6 +28,7 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.runs.RunCanceled; import io.seqera.tower.cli.responses.runs.RunDeleted; +import io.seqera.tower.cli.responses.runs.RunLog; import io.seqera.tower.cli.responses.runs.RunStarred; import io.seqera.tower.cli.responses.runs.RunsDeleted; import io.seqera.tower.cli.responses.runs.RunDump; @@ -790,6 +791,38 @@ void testDownloadTaskLog(MockServerClient mock) throws IOException { assertEquals(0, out.exitCode); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testLog(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": ["N E X T F L O W ~ version 26.04.6", "Launching `main.nf`"], "pending": true, "truncated": false, "downloads": []}}""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log"); + assertOutput(format, out, new RunLog(List.of("N E X T F L O W ~ version 26.04.6", "Launching `main.nf`"), false, null)); + } + + @Test + void testTaskLogTruncatedWithMessage(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log/5"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": ["Hello"], "pending": false, "truncated": true, "message": "Unable to retrieve output log - Cause: gone"}}""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log", "-t", "5"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new RunLog(List.of("Hello"), true, "Unable to retrieve output log - Cause: gone").toString()), out.stdOut); + assertTrue(out.stdOut.startsWith("Hello"), out.stdOut); + assertTrue(out.stdOut.contains("Log truncated"), out.stdOut); + } + @Test void testDumpRuns(MockServerClient mock) throws IOException { From d78d70ae9bd7e72c4c9099833a40c8a3dc352950 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:18:40 +0200 Subject: [PATCH 10/28] feat(runs): page through run logs with --next Live log streams such as CloudWatch return one page per call (up to 10k events or 1 MB) without flagging truncation. So 'runs view log' printed the first page of a long log as if it were all of it. Print the forward cursor and accept it with --next (the 'next' query parameter of GET /workflow/{id}/log, in the API since at least 1.101.0). An empty page ends the hint, because CloudWatch keeps returning a cursor at the end of the stream. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/runs/log/LogCmd.java | 14 +++++++-- .../tower/cli/responses/runs/RunLog.java | 7 ++++- .../io/seqera/tower/cli/runs/RunsCmdTest.java | 29 +++++++++++++++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java index 4b18a14c0..24197e007 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java @@ -36,6 +36,9 @@ public class LogCmd extends AbstractRunsCmd { @CommandLine.Option(names = {"-t"}, description = "Task numeric identifier. When specified, displays the task output log instead of the Nextflow head job output.") public Long task; + @CommandLine.Option(names = {"--next"}, description = "Pagination cursor of the log page to display, as printed at the end of the previous page.") + public String next; + @CommandLine.ParentCommand ViewCmd parentCommand; @@ -46,10 +49,15 @@ protected Response exec() throws ApiException { // Platform serves the compute platform's live log stream when it has one, so unlike 'download' this // also works while the run is active; otherwise it falls back to the output file. LogPage page = task == null - ? workflowsApi().getWorkflowLog(parentCommand.id, wspId, null, null).getLog() - : workflowsApi().getWorkflowTaskLog(parentCommand.id, task, null, wspId, null).getLog(); + ? workflowsApi().getWorkflowLog(parentCommand.id, wspId, next, null).getLog() + : workflowsApi().getWorkflowTaskLog(parentCommand.id, task, next, wspId, null).getLog(); - return new RunLog(entries(page), Boolean.TRUE.equals(page.getTruncated()), page.getMessage()); + // Live streams (e.g. CloudWatch) return one page per call without flagging truncation; an empty page means + // the end of what is available, even though the stream keeps returning a cursor + List entries = entries(page); + boolean hasEntries = entries != null && !entries.isEmpty(); + String nextPage = hasEntries ? page.getForwardToken() : null; + return new RunLog(entries, Boolean.TRUE.equals(page.getTruncated()), page.getMessage(), nextPage); } // The API spec types LogPage.entries as an Iterator object, so the SDK exposes it as Object, but Platform sends a JSON diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java index 82d75bdbc..fd4c4cce8 100644 --- a/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java @@ -27,11 +27,13 @@ public class RunLog extends Response { public final List entries; public final boolean truncated; public final String message; + public final String next; - public RunLog(List entries, boolean truncated, String message) { + public RunLog(List entries, boolean truncated, String message, String next) { this.entries = entries; this.truncated = truncated; this.message = message; + this.next = next; } @Override @@ -45,5 +47,8 @@ public void toString(PrintWriter out) { if (truncated) { out.println(ansi(String.format("%n @|yellow Log truncated. Use 'tw runs view download' to get the complete file once the run has finished.|@"))); } + if (next != null) { + out.println(ansi(String.format("%n @|bold Next page:|@ --next %s", next))); + } } } 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 796fa140a..41fdbb79c 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -802,7 +802,32 @@ void testLog(OutputType format, MockServerClient mock) { ); ExecOut out = exec(format, mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log"); - assertOutput(format, out, new RunLog(List.of("N E X T F L O W ~ version 26.04.6", "Launching `main.nf`"), false, null)); + assertOutput(format, out, new RunLog(List.of("N E X T F L O W ~ version 26.04.6", "Launching `main.nf`"), false, null, null)); + } + + @Test + void testLogPagesWithForwardToken(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log").withQueryStringParameter("next", "f/123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": ["second page"], "truncated": false, "forwardToken": "f/456"}}""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log").withQueryStringParameter("next", "f/456"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": [], "truncated": false, "forwardToken": "f/456"}}""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut page = exec(mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log", "--next", "f/123"); + assertTrue(page.stdOut.startsWith("second page"), page.stdOut); + assertTrue(page.stdOut.contains("--next f/456"), page.stdOut); + + // An empty page is the end of the available log: no cursor hint even though the stream returns one + ExecOut end = exec(mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log", "--next", "f/456"); + assertEquals(0, end.exitCode); + assertTrue(!end.stdOut.contains("--next"), end.stdOut); } @Test @@ -818,7 +843,7 @@ void testTaskLogTruncatedWithMessage(MockServerClient mock) { assertEquals("", out.stdErr); assertEquals(0, out.exitCode); - assertEquals(chop(new RunLog(List.of("Hello"), true, "Unable to retrieve output log - Cause: gone").toString()), out.stdOut); + assertEquals(chop(new RunLog(List.of("Hello"), true, "Unable to retrieve output log - Cause: gone", null).toString()), out.stdOut); assertTrue(out.stdOut.startsWith("Hello"), out.stdOut); assertTrue(out.stdOut.contains("Log truncated"), out.stdOut); } From ee2a7bef9d481f08c98ca10a63dbd2ed0bfd252a Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:18:42 +0200 Subject: [PATCH 11/28] fix(pipelines): keep labels and icon on 'tw pipelines update' The update sent no labelIds and no icon. Platform's update replaces the pipeline's labels with labelIds; the API docs for both PUT /pipelines/{id} and POST /pipelines/{id}/versions/{versionId} say a null or omitted labelIds removes existing labels. It also sets the icon to the request value and, when that is null, recomputes it from the repository, so an uploaded icon was replaced. The web form sends back the current labels and icon; tw now does the same, reading the labels through describePipeline's 'labels' attribute. Found while checking whether moving to updatePipeline (PUT /pipelines/{id}) would gain anything. It would not: both endpoints call the same PipelineService.updatePipeline, and PUT is the versionId=null case, which only adds auto-promoting a new draft version to default. tw already handles that, with --allow-draft on top. The label loss is inferred from the Platform source and API docs, not reproduced against a live Platform: LabelRefServiceImpl.applyLabelsToEntities evaluates labelIds.contains(...) for each existing label. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/pipelines/UpdateCmd.java | 7 ++++++- .../io/seqera/tower/cli/pipelines/PipelinesCmdTest.java | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java index 073ef725a..c968dcea1 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java @@ -25,8 +25,10 @@ import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.cli.utils.ModelHelper; import io.seqera.tower.cli.utils.VersionNameHelper; +import io.seqera.tower.model.LabelDbDto; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineQueryAttribute; import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.PipelineVersionManageRequest; import io.seqera.tower.model.UpdatePipelineRequest; @@ -82,7 +84,8 @@ protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); // Fetch uses describePipeline, which returns the default version on pipe.getVersion() - PipelineDbDto pipe = fetchPipeline(pipelineRefOptions, wspId); + // Labels are fetched only to be sent back: the update replaces the pipeline's labels with the given ones. + PipelineDbDto pipe = fetchPipeline(pipelineRefOptions, wspId, PipelineQueryAttribute.labels); // If the user wants to rename the pipeline (--new-name), validate early before making any // changes. The server checks uniqueness within the workspace/org scope. @@ -229,6 +232,8 @@ private UpdatePipelineRequest buildUpdateRequest(PipelineDbDto pipe, LaunchDbDto return new UpdatePipelineRequest() .name(coalesce(newName, pipe.getName())) .description(coalesce(description, pipe.getDescription())) + .icon(pipe.getIcon()) + .labelIds(pipe.getLabels() == null ? null : pipe.getLabels().stream().map(LabelDbDto::getId).toList()) .launch(launchRequest); } 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 6214bedf6..ec8a19fd2 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -1240,7 +1240,7 @@ void testAddWithInvalidSyntaxParser(MockServerClient mock) { } @Test - void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { + void testUpdateKeepsStoredConfigurationLabelsAndIcon(MockServerClient mock) { mock.reset(); @@ -1251,7 +1251,8 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863") + .withQueryStringParameter("attributes", "labels"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { @@ -1259,6 +1260,8 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { "pipelineId": 217997727159863, "name": "sleep_one_minute", "repository": "https://github.com/pditommaso/nf-sleep", + "icon": "https://avatars.example.com/custom.png", + "labels": [{"id": 11, "name": "team", "value": "rnd", "resource": true}, {"id": 12, "name": "prod", "resource": false}], "version": {"id": "default-ver", "name": "sleep_one_minute-1", "isDefault": true} } }""").withContentType(MediaType.APPLICATION_JSON) @@ -1276,6 +1279,8 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") .withBody(json(""" { + "icon":"https://avatars.example.com/custom.png", + "labelIds":[11,12], "launch":{ "computeEnvId":"vYOK4vn7spw7bHHWBDXZ2", "pipeline":"https://github.com/pditommaso/nf-sleep", From b24b2c063d7b42010a91f2b6acefd5e6a93677ba Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:18:44 +0200 Subject: [PATCH 12/28] feat(pipelines): add 'tw pipelines schema' to read a pipeline's parameter schema GET /pipelines/{pipelineId}/schema (describePipelineSchema, in the API since at least 1.101.0) returns the schema Platform uses to render a pipeline's launch form: the custom schema attached to the launch configuration if there is one, otherwise the repository's nextflow_schema.json. tw could attach a custom schema ('tw pipeline-schemas add', 'tw pipelines add --pipeline-schema-id') but never read one back, and had no way to see which parameters a pipeline takes before writing a --params-file. 'tw pipelines schema -n ' prints the schema; '--params' prints the parameter values the pipeline launches with instead (pipeline defaults merged with the stored launch parameters). --version-id/--version-name select a version, as in 'tw pipelines view'. Platform answers 204 when it cannot resolve a schema; that is reported as an error rather than empty output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/PipelinesCmd.java | 2 + .../cli/commands/pipelines/SchemaCmd.java | 71 +++++++++++++++++++ .../responses/pipelines/PipelinesSchema.java | 44 ++++++++++++ .../tower/cli/pipelines/PipelinesCmdTest.java | 50 +++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java diff --git a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java index f4c50ce55..fcdc27ec9 100644 --- a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java @@ -22,6 +22,7 @@ import io.seqera.tower.cli.commands.pipelines.ImportCmd; import io.seqera.tower.cli.commands.pipelines.labels.LabelsCmd; import io.seqera.tower.cli.commands.pipelines.ListCmd; +import io.seqera.tower.cli.commands.pipelines.SchemaCmd; import io.seqera.tower.cli.commands.pipelines.UpdateCmd; import io.seqera.tower.cli.commands.pipelines.ViewCmd; import io.seqera.tower.cli.commands.pipelines.versions.VersionsCmd; @@ -40,6 +41,7 @@ ExportCmd.class, ImportCmd.class, LabelsCmd.class, + SchemaCmd.class, VersionsCmd.class } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java new file mode 100644 index 000000000..3256b5bd9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java @@ -0,0 +1,71 @@ +/* + * 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.pipelines; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.pipelines.PipelinesSchema; +import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineSchemaAttributes; +import io.seqera.tower.model.PipelineSchemaResponse; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.util.List; + +@Command( + name = "schema", + description = "Display the parameter schema of a pipeline" +) +public class SchemaCmd extends AbstractPipelinesCmd { + + @CommandLine.Mixin + PipelineRefOptions pipelineRefOptions; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. + @CommandLine.ArgGroup(multiplicity = "0..1") + public VersionRefOptions.VersionRef versionRef; + + @Option(names = {"--params"}, description = "Display the parameter values the pipeline launches with (pipeline defaults merged with the launch configuration) instead of the schema.") + public boolean params; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); + String versionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRef); + PipelineSchemaAttributes attribute = params ? PipelineSchemaAttributes.params : PipelineSchemaAttributes.schema; + + // Platform answers 204 when it cannot resolve a schema, which the SDK returns as null. + PipelineSchemaResponse response = pipelinesApi().describePipelineSchema( + pipeline.getPipelineId(), wspId, sourceWorkspaceId(wspId, pipeline), List.of(attribute), versionId); + String content = response == null ? null : params ? response.getParams() : response.getSchema(); + if (content == null) { + throw new TowerException(String.format("No %s found for pipeline '%s'", attribute, pipeline.getName())); + } + + return new PipelinesSchema(attribute, content); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java new file mode 100644 index 000000000..6a8d2847c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.pipelines; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.PipelineSchemaAttributes; + +import java.util.Map; + +public class PipelinesSchema extends Response { + + public final PipelineSchemaAttributes type; + public final String content; + + public PipelinesSchema(PipelineSchemaAttributes type, String content) { + this.type = type; + this.content = content; + } + + @Override + public Object getJSON() { + return Map.of(type.getValue(), content); + } + + @Override + public String toString() { + return content; + } +} 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 ec8a19fd2..b3f8577e2 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -35,6 +35,7 @@ import io.seqera.tower.cli.responses.pipelines.PipelinesExport; import io.seqera.tower.cli.responses.pipelines.PipelinesList; import io.seqera.tower.cli.responses.pipelines.PipelinesUpdated; +import io.seqera.tower.cli.responses.pipelines.PipelinesSchema; import io.seqera.tower.cli.responses.pipelines.PipelinesView; import io.seqera.tower.cli.utils.ModelHelper; import io.seqera.tower.cli.utils.PaginationInfo; @@ -42,6 +43,7 @@ import io.seqera.tower.model.CreatePipelineRequest; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineSchemaAttributes; import io.seqera.tower.model.WorkflowLaunchRequest; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; @@ -1996,6 +1998,54 @@ void testRemoveLabels(OutputType format, MockServerClient mock) { // --- Version ID / Version Name wiring tests --- + @ParameterizedTest + @EnumSource(OutputType.class) + void testSchema(OutputType format, MockServerClient mock) { + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/schema") + .withQueryStringParameter("attributes", "schema"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"schema\":\"{\\\"title\\\":\\\"nf-sleep\\\"}\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "pipelines", "schema", "-i", "217997727159863"); + assertOutput(format, out, new PipelinesSchema(PipelineSchemaAttributes.schema, "{\"title\":\"nf-sleep\"}")); + if (format == OutputType.console) { + assertEquals("{\"title\":\"nf-sleep\"}", out.stdOut); + } + } + + @Test + void testSchemaParamsNotFound(MockServerClient mock) { + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/schema") + .withQueryStringParameter("attributes", "params"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "pipelines", "schema", "-i", "217997727159863", "--params"); + + assertEquals(errorMessage(out.app, new TowerException("No params found for pipeline 'sleep_one_minute'")), out.stdErr); + assertEquals(1, out.exitCode); + } + @Test void testViewWithVersionId(MockServerClient mock) throws JsonProcessingException { From 8bded2d3972cf1e61a7a772a19233176b4f10219 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:20:08 +0200 Subject: [PATCH 13/28] feat(studios): add extend and rename-checkpoint Platform API (at least since 1.101.0) exposes studio operations that tw never wired up: lifespan extension (POST /studios/{sessionId}/lifespan) and checkpoint renaming (PUT /studios/{sessionId}/checkpoints/{checkpointId}). This adds 'tw studios extend|rename-checkpoint' so CLI users can do what the UI already does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/StudiosCmd.java | 4 ++ .../tower/cli/commands/studios/ExtendCmd.java | 47 ++++++++++++++ .../commands/studios/RenameCheckpointCmd.java | 56 +++++++++++++++++ .../studios/StudioCheckpointUpdated.java | 39 ++++++++++++ .../studios/StudioLifespanExtended.java | 37 +++++++++++ .../tower/cli/studios/StudiosCmdTest.java | 63 +++++++++++++++++++ 6 files changed, 246 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java diff --git a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java index fce6cb9d7..345d7d48f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -19,6 +19,8 @@ import io.seqera.tower.cli.commands.studios.AddCmd; import io.seqera.tower.cli.commands.studios.CheckpointsCmd; import io.seqera.tower.cli.commands.studios.DeleteCmd; +import io.seqera.tower.cli.commands.studios.ExtendCmd; +import io.seqera.tower.cli.commands.studios.RenameCheckpointCmd; import io.seqera.tower.cli.commands.studios.ListCmd; import io.seqera.tower.cli.commands.studios.AddAsNewCmd; import io.seqera.tower.cli.commands.studios.StartCmd; @@ -38,10 +40,12 @@ AddCmd.class, TemplatesCmd.class, CheckpointsCmd.class, + RenameCheckpointCmd.class, AddAsNewCmd.class, StopCmd.class, UpdateCmd.class, DeleteCmd.class, + ExtendCmd.class, } ) public class StudiosCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.java new file mode 100644 index 000000000..a2a26d9cd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; +import io.seqera.tower.model.DataStudioDto; +import picocli.CommandLine; + +@CommandLine.Command( + name = "extend", + description = "Extend the lifespan of a running studio that is approaching its scheduled auto-stop." +) +public class ExtendCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + DataStudioDto studio = studiosApi().extendDataStudioLifespan(sessionId, wspId); + + return new StudioLifespanExtended(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), studio.getEffectiveLifespanHours()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java new file mode 100644 index 000000000..f72a088e4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; +import io.seqera.tower.model.DataStudioCheckpointDto; +import io.seqera.tower.model.DataStudioCheckpointUpdateRequest; +import picocli.CommandLine; + +@CommandLine.Command( + name = "rename-checkpoint", + description = "Rename a studio checkpoint." +) +public class RenameCheckpointCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @CommandLine.Option(names = {"--checkpoint-id"}, description = "Checkpoint numeric identifier, as listed by the 'checkpoints' command.", required = true) + public Long checkpointId; + + @CommandLine.Option(names = {"--new-name"}, description = "New checkpoint name. Must be unique per studio.", required = true) + public String newName; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + DataStudioCheckpointUpdateRequest request = new DataStudioCheckpointUpdateRequest(); + request.setName(newName); + DataStudioCheckpointDto checkpoint = studiosApi().updateDataStudioCheckpoint(sessionId, checkpointId, request, wspId); + + return new StudioCheckpointUpdated(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), checkpoint.getId(), checkpoint.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.java new file mode 100644 index 000000000..3f50ed035 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.responses.studios; + +import io.seqera.tower.cli.responses.Response; + +public class StudioCheckpointUpdated extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final Long checkpointId; + public final String name; + + public StudioCheckpointUpdated(String userSuppliedStudioIdentifier, String workspaceRef, Long checkpointId, String name) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.checkpointId = checkpointId; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Checkpoint %d of studio %s at %s workspace renamed to '%s'.|@%n", checkpointId, userSuppliedStudioIdentifier, workspaceRef, name)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java new file mode 100644 index 000000000..5bae89dac --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.responses.studios; + +import io.seqera.tower.cli.responses.Response; + +public class StudioLifespanExtended extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final Integer lifespanHours; + + public StudioLifespanExtended(String userSuppliedStudioIdentifier, String workspaceRef, Integer lifespanHours) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.lifespanHours = lifespanHours; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Studio %s lifespan at %s workspace extended to %d hours.|@%n", userSuppliedStudioIdentifier, workspaceRef, lifespanHours)); + } +} diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index 12704ca5d..e26600695 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -29,6 +29,8 @@ import io.seqera.tower.cli.responses.studios.StudiosList; import io.seqera.tower.cli.responses.studios.StudioCheckpointsList; import io.seqera.tower.cli.responses.studios.StudioDeleted; +import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; +import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; import io.seqera.tower.cli.responses.studios.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; import io.seqera.tower.cli.responses.studios.StudioUpdated; @@ -2851,4 +2853,65 @@ void testAddWithoutTemplateOrUrlFails(MockServerClient mock) { assertTrue(out.stdErr.contains("A studio template is required"), out.stdErr); } + private void mockWorkspace(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/user/1264/workspaces"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testExtend(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/lifespan").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"sessionId\": \"3e8370e7\", \"effectiveLifespanHours\": 16}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "extend", "-w", "75887156211589", "-i", "3e8370e7"); + + assertOutput(format, out, new StudioLifespanExtended("3e8370e7", "[organization1 / workspace1]", 16)); + mock.verify(request().withMethod("POST").withPath("/studios/3e8370e7/lifespan"), VerificationTimes.once()); + } + + @Test + void testExtendNotApproachingExpiration(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/lifespan").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(400).withBody("{\"message\": \"Studio lifespan can only be extended once it is approaching its scheduled auto-stop.\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "extend", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("approaching its scheduled auto-stop"), out.stdErr); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testRenameCheckpoint(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("PUT").withPath("/studios/3e8370e7/checkpoints/1").withQueryStringParameter("workspaceId", "75887156211589") + .withBody(json("{\"name\": \"before-upgrade\"}", MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"id\": 1, \"name\": \"before-upgrade\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "rename-checkpoint", "-w", "75887156211589", "-i", "3e8370e7", "--checkpoint-id", "1", "--new-name", "before-upgrade"); + + assertOutput(format, out, new StudioCheckpointUpdated("3e8370e7", "[organization1 / workspace1]", 1L, "before-upgrade")); + mock.verify(request().withMethod("PUT").withPath("/studios/3e8370e7/checkpoints/1"), VerificationTimes.once()); + } + } From 9dc115a50596b036749c871aa1b8d726d5d24444 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:21:39 +0200 Subject: [PATCH 14/28] feat(studios): show progress warnings while waiting DataStudioProgressStep.warnings (in the API since at least 1.101.0; e.g. cross-region data, failed checkpoint mounts) were never shown. They are now printed once each while waiting on a studio with '--wait'. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../commands/studios/AbstractStudiosCmd.java | 24 ++++++++++-- .../tower/cli/studios/StudiosCmdTest.java | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) 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 bc34037a2..093eca991 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 @@ -17,9 +17,11 @@ package io.seqera.tower.cli.commands.studios; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; import io.seqera.tower.ApiException; @@ -220,6 +222,7 @@ public class ProgressStepMessageSupplier implements Supplier { private final String sessionId; private final Long workspaceId; private DataStudioProgressStep currentProgressStep; + private final Set printedWarnings = new HashSet<>(); public ProgressStepMessageSupplier(String sessionId, Long workspaceId) { this.sessionId = sessionId; @@ -231,22 +234,35 @@ public ProgressStepMessageSupplier(String sessionId, Long workspaceId) { public String get() { try { DataStudioDto studioDto = studiosApi().describeDataStudio(sessionId, workspaceId); + StringBuilder messages = new StringBuilder(); Optional inProgressStep = studioDto.getProgress().stream() .filter(step -> step.getStatus() == IN_PROGRESS || step.getStatus() == ERRORED) .findFirst(); - if (inProgressStep.isPresent() && !inProgressStep.get().equals(currentProgressStep)) { + // Compare without warnings: a step gaining a warning is still the same step. + if (inProgressStep.isPresent() && !isSameStep(inProgressStep.get(), currentProgressStep)) { currentProgressStep = inProgressStep.get(); - return currentProgressStep.getStatus() != ERRORED + messages.append(currentProgressStep.getStatus() != ERRORED ? String.format("\n %s", currentProgressStep.getMessage()) - : String.format("\n %s - Error encountered: %s", currentProgressStep.getMessage(), studioDto.getStatusInfo().getMessage()); + : String.format("\n %s - Error encountered: %s", currentProgressStep.getMessage(), studioDto.getStatusInfo().getMessage())); } - return ""; + // Warnings can also be attached to steps that already succeeded. + studioDto.getProgress().stream() + .filter(step -> step.getWarnings() != null) + .flatMap(step -> step.getWarnings().stream()) + .filter(printedWarnings::add) + .forEach(warning -> messages.append(String.format("\n Warning: %s", warning))); + + return messages.toString(); } catch (Exception e) { return ""; } } + + private boolean isSameStep(DataStudioProgressStep a, DataStudioProgressStep b) { + return a.getStatus() == b.getStatus() && Objects.equals(a.getMessage(), b.getMessage()); + } } } diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index e26600695..b1e79c0c0 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -2914,4 +2914,41 @@ void testRenameCheckpoint(OutputType format, MockServerClient mock) { mock.verify(request().withMethod("PUT").withPath("/studios/3e8370e7/checkpoints/1"), VerificationTimes.once()); } + @Test + void testStartWithWaitShowsProgressWarnings(MockServerClient mock) { + mockWorkspace(mock); + String regionWarning = "Mounting data from regions that differ from the compute environment may result in increased costs."; + String checkpointWarning = "Checkpoint 2 could not be mounted."; + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_view_response_studio_stopped")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("PUT").withPath("/studios/3e8370e7/start").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_start_response")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7").withQueryStringParameter("workspaceId", "75887156211589"), exactly(2) + ).respond( + response().withStatusCode(200).withBody(new String(loadResource("studios/studios_view_response_studio_starting")) + .replaceFirst("\"warnings\": null", "\"warnings\": [\"" + regionWarning + "\"]")).withContentType(MediaType.APPLICATION_JSON) + ); + // the region warning is still reported once the step succeeds, it must not be printed twice + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7").withQueryStringParameter("workspaceId", "75887156211589") + ).respond( + response().withStatusCode(200).withBody(new String(loadResource("studios/studios_view_response")) + .replaceFirst("\"warnings\": null", "\"warnings\": [\"" + regionWarning + "\"]") + .replaceFirst("\"warnings\": null", "\"warnings\": [\"" + checkpointWarning + "\"]")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "start", "-w", "75887156211589", "-i", "3e8370e7", "--wait", "RUNNING"); + + assertEquals(0, out.exitCode, out.stdErr); + assertEquals(1, out.stdOut.split(java.util.regex.Pattern.quote("Warning: " + regionWarning), -1).length - 1, out.stdOut); + assertTrue(out.stdOut.contains("Warning: " + checkpointWarning), out.stdOut); + } + } From 84cc776c2169c0001558111aec66ee583f554c65 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:21:41 +0200 Subject: [PATCH 15/28] feat(data-links): add delete-content and download-script Two data-explorer operations available since API 1.101.0 had no tw command: - 'tw data-links delete-content' calls DELETE /data-links/{id}/content to delete files (--file) and folders with their contents (--dir) while keeping the data link. The platform supports it for Seqera Compute data links only, and answers a partial deletion with 500 plus the failed items, which tw reports per item instead of a bare error. - 'tw data-links download-script' calls GET /data-links/{id}/script/download and prints the cloud CLI commands (aws s3 cp, gcloud storage cp, ...) the UI offers for downloading. GET /data-links/{id}/download/{filePath} is not added: it streams the same file through the platform, with a size limit, that 'tw data-links download' already fetches through a signed URL. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/DataLinksCmd.java | 4 + .../commands/data/links/DeleteContentCmd.java | 93 ++++++++++++++++++ .../data/links/DownloadScriptCmd.java | 64 +++++++++++++ .../data/DataLinkContentDeleted.java | 45 +++++++++ .../data/DataLinkDownloadScript.java | 33 +++++++ .../tower/cli/data/DataLinksCmdTest.java | 96 +++++++++++++++++++ 6 files changed, 335 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java diff --git a/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java b/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java index d802cd21a..0e56fbe25 100644 --- a/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.cli.commands.data.links.AddCmd; import io.seqera.tower.cli.commands.data.links.DeleteCmd; +import io.seqera.tower.cli.commands.data.links.DeleteContentCmd; +import io.seqera.tower.cli.commands.data.links.DownloadScriptCmd; import io.seqera.tower.cli.commands.data.links.DownloadCmd; import io.seqera.tower.cli.commands.data.links.ListCmd; import io.seqera.tower.cli.commands.data.links.UpdateCmd; @@ -32,9 +34,11 @@ ListCmd.class, AddCmd.class, DeleteCmd.class, + DeleteContentCmd.class, UpdateCmd.class, BrowseCmd.class, DownloadCmd.class, + DownloadScriptCmd.class, UploadCmd.class } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java b/src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java new file mode 100644 index 000000000..bb5614e3e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java @@ -0,0 +1,93 @@ +/* + * 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.data.links; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.data.DataLinkContentDeleted; +import io.seqera.tower.model.DataLinkDeleteItemRequest; +import io.seqera.tower.model.DataLinkDeleteItemResponse; +import picocli.CommandLine; + +import java.util.List; +import java.util.stream.Collectors; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; + +@CommandLine.Command( + name = "delete-content", + description = "Delete files or folders from a data link. Supported for Seqera Compute data links only." +) +public class DeleteContentCmd extends AbstractDataLinksCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public DataLinkRefOptions dataLinkRefOptions; + + @CommandLine.Option(names = {"-c", "--credentials"}, description = "Credentials identifier", required = true) + public String credentialsRef; + + @CommandLine.Option(names = {"--file"}, description = "File path to delete, relative to the data link root. Can be specified multiple times.") + public List files; + + @CommandLine.Option(names = {"--dir"}, description = "Folder path to delete with all its contents, relative to the data link root. Can be specified multiple times.") + public List dirs; + + @Override + protected Response exec() throws ApiException { + if (files == null && dirs == null) { + throw new TowerException("Provide at least one --file or --dir to delete"); + } + + Long wspId = workspaceId(workspace.workspace); + String credId = credentialsByRef(null, wspId, credentialsRef); + String id = getDataLinkId(dataLinkRefOptions, wspId, credId); + + DataLinkDeleteItemRequest request = new DataLinkDeleteItemRequest().files(files).dirs(dirs); + try { + dataLinksApi().deleteDataLinkItem(id, request, wspId, credId); + } catch (ApiException e) { + // The platform answers 500 with the list of items it could not delete when the deletion is partial. + DataLinkDeleteItemResponse failed = e.getCode() == 500 ? parseDeletionFailures(e.getResponseBody()) : null; + if (failed == null || failed.getDeletionFailures() == null || failed.getDeletionFailures().isEmpty()) { + throw e; + } + String failures = failed.getDeletionFailures().stream() + .map(f -> String.format("'%s': %s", f.getDataLinkItem() == null ? "" : f.getDataLinkItem().getName(), f.getErrorMessage())) + .collect(Collectors.joining(", ")); + throw new TowerException(String.format("Failed to delete some items from data link '%s': %s", id, failures)); + } + + return new DataLinkContentDeleted(id, wspId, files, dirs); + } + + private static DataLinkDeleteItemResponse parseDeletionFailures(String body) { + if (body == null) { + return null; + } + try { + return parseJson(body, DataLinkDeleteItemResponse.class); + } catch (JsonProcessingException e) { + return null; + } + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java b/src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java new file mode 100644 index 000000000..4124f5d18 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java @@ -0,0 +1,64 @@ +/* + * 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.data.links; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.data.DataLinkDownloadScript; +import io.seqera.tower.model.DataLinkDownloadScriptResponse; +import picocli.CommandLine; + +import java.util.ArrayList; +import java.util.List; + +@CommandLine.Command( + name = "download-script", + description = "Print the cloud provider CLI commands that download data link contents. Downloads the whole data link when no --file or --dir is given." +) +public class DownloadScriptCmd extends AbstractDataLinksCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public DataLinkRefOptions dataLinkRefOptions; + + @CommandLine.Option(names = {"-c", "--credentials"}, description = "Credentials identifier") + public String credentialsRef; + + @CommandLine.Option(names = {"--file"}, description = "File path to download, relative to the data link root. Can be specified multiple times.") + public List files; + + @CommandLine.Option(names = {"--dir"}, description = "Folder path to download, relative to the data link root. Can be specified multiple times.") + public List dirs; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String credId = credentialsRef != null ? credentialsByRef(null, wspId, credentialsRef) : null; + String id = getDataLinkId(dataLinkRefOptions, wspId, credId); + + DataLinkDownloadScriptResponse response = dataLinksApi().generateDownloadScript(id, wspId, credId, toObjects(dirs), toObjects(files)); + + return new DataLinkDownloadScript(response.getScript()); + } + + private static List toObjects(List paths) { + return paths == null ? null : new ArrayList<>(paths); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java new file mode 100644 index 000000000..95ca3905b --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.responses.data; + +import io.seqera.tower.cli.responses.Response; + +import java.util.List; + +public class DataLinkContentDeleted extends Response { + + public final String id; + public final Long wspId; + public final List files; + public final List dirs; + + public DataLinkContentDeleted(String id, Long wspId, List files, List dirs) { + this.id = id; + this.wspId = wspId; + this.files = files; + this.dirs = dirs; + } + + @Override + public String toString() { + int count = (files == null ? 0 : files.size()) + (dirs == null ? 0 : dirs.size()); + if (wspId != null) { + return ansi(String.format("%n @|yellow %d items deleted from data link '%s' at '%s' workspace.|@%n", count, id, wspId)); + } + return ansi(String.format("%n @|yellow %d items deleted from data link '%s' at user workspace.|@%n", count, id)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java new file mode 100644 index 000000000..62838aa24 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java @@ -0,0 +1,33 @@ +/* + * 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.data; + +import io.seqera.tower.cli.responses.Response; + +public class DataLinkDownloadScript extends Response { + + public final String script; + + public DataLinkDownloadScript(String script) { + this.script = script; + } + + @Override + public String toString() { + return script; + } +} diff --git a/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java b/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java index d0c20df07..d7fcf9351 100644 --- a/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java @@ -25,7 +25,9 @@ import io.seqera.tower.cli.commands.data.links.upload.AbstractProviderUploader; import io.seqera.tower.cli.commands.enums.OutputType; import io.seqera.tower.cli.exceptions.TowerRuntimeException; +import io.seqera.tower.cli.responses.data.DataLinkContentDeleted; import io.seqera.tower.cli.responses.data.DataLinkDeleted; +import io.seqera.tower.cli.responses.data.DataLinkDownloadScript; import io.seqera.tower.cli.responses.data.DataLinkFileTransferResult; import io.seqera.tower.cli.responses.data.DataLinksList; import io.seqera.tower.cli.utils.PaginationInfo; @@ -1766,4 +1768,98 @@ private void deleteDirectory(Path directory) throws IOException { }); } } + + private void mockCredentials(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/credentials").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentials\":[{\"id\":\"57Ic6reczFn78H1DTaaXkp\",\"name\":\"aws\",\"discriminator\":\"aws\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteContent(OutputType format, MockServerClient mock) { + mockCredentials(mock); + mock.when( + request().withMethod("DELETE").withPath("/data-links/v1-datalinkid/content") + .withQueryStringParameter("workspaceId", "75887156211589") + .withQueryStringParameter("credentialsId", "57Ic6reczFn78H1DTaaXkp") + .withBody(json("{\"files\": [\"a.csv\", \"b/c.csv\"], \"dirs\": [\"results/\"]}", MatchType.STRICT)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"deletionFailures\": []}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "data-links", "delete-content", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws", + "--file", "a.csv", "--file", "b/c.csv", "--dir", "results/"); + + assertOutput(format, out, new DataLinkContentDeleted("v1-datalinkid", 75887156211589L, List.of("a.csv", "b/c.csv"), List.of("results/"))); + } + + @org.junit.jupiter.api.Test + void testDeleteContentPartialFailure(MockServerClient mock) { + mockCredentials(mock); + mock.when( + request().withMethod("DELETE").withPath("/data-links/v1-datalinkid/content"), exactly(1) + ).respond( + response().withStatusCode(500).withBody("{\"deletionFailures\": [{\"dataLinkItem\": {\"name\": \"a.csv\", \"type\": \"FILE\"}, \"errorMessage\": \"Access denied\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "data-links", "delete-content", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws", "--file", "a.csv"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Failed to delete some items from data link 'v1-datalinkid': 'a.csv': Access denied"), out.stdErr); + } + + @org.junit.jupiter.api.Test + void testDeleteContentRequiresPaths(MockServerClient mock) { + ExecOut out = exec(mock, "data-links", "delete-content", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Provide at least one --file or --dir to delete"), out.stdErr); + mock.verify(request().withMethod("DELETE"), VerificationTimes.never()); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDownloadScript(OutputType format, MockServerClient mock) { + mockCredentials(mock); + String script = "aws s3 cp s3://bucket/results results --recursive &&\naws s3 cp s3://bucket/a.csv a.csv"; + mock.when( + request().withMethod("GET").withPath("/data-links/v1-datalinkid/script/download") + .withQueryStringParameter("workspaceId", "75887156211589") + .withQueryStringParameter("credentialsId", "57Ic6reczFn78H1DTaaXkp") + .withQueryStringParameter("dirs", "results") + .withQueryStringParameter("files", "a.csv"), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(json(java.util.Map.of("script", script))).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "data-links", "download-script", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws", + "--dir", "results", "--file", "a.csv"); + + assertOutput(format, out, new DataLinkDownloadScript(script)); + if (format == OutputType.console) { + assertEquals(script, out.stdOut); + } + } + + @org.junit.jupiter.api.Test + void testDownloadScriptWholeDataLink(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/data-links/v1-datalinkid/script/download") + .withQueryStringParameter("workspaceId", "75887156211589"), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"script\": \"aws s3 cp s3://bucket bucket --recursive\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "data-links", "download-script", "-w", "75887156211589", "-i", "v1-datalinkid"); + + assertEquals(0, out.exitCode, out.stdErr); + assertEquals("aws s3 cp s3://bucket bucket --recursive", out.stdOut); + mock.verify(request().withPath("/data-links/v1-datalinkid/script/download").withQueryStringParameter("files", ".*"), VerificationTimes.never()); + } } From 23a45a10329d164f611f9a776997ead1b5db92c0 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:21:43 +0200 Subject: [PATCH 16/28] fix(data-links): stop printing a Jersey warning on delete-content DELETE /data-links/{id}/content takes a request body. The SDK disables Jersey's HTTP compliance check, so the body is sent, but Jersey still logs 'Entity must be null for http method DELETE' to stderr through JUL on every call. In-process JVM tests don't capture System.err, so only the native binary tests caught it. Raise that logger to SEVERE. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/io/seqera/tower/cli/commands/AbstractApiCmd.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 20d15b2d3..dcbde8236 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -98,6 +98,14 @@ public abstract class AbstractApiCmd extends AbstractCmd { public static final String USER_WORKSPACE_NAME = "user"; public static final String WORKSPACE_REF_SEPARATOR = "/"; + // Some endpoints take a body on DELETE (e.g. data-link content). The SDK suppresses Jersey's HTTP compliance + // check, so the request is sent, but Jersey still logs a warning to stderr. Strong reference: JUL holds loggers weakly. + private static final Logger JERSEY_INVOCATION_LOGGER = Logger.getLogger("org.glassfish.jersey.client.JerseyInvocation"); + + static { + JERSEY_INVOCATION_LOGGER.setLevel(java.util.logging.Level.SEVERE); + } + // No attributes constants public static final List NO_CE_ATTRIBUTES = Collections.EMPTY_LIST; public static final List NO_WORKFLOW_ATTRIBUTES = Collections.EMPTY_LIST; From 759b7a8e61c9a1a95f56b65baf80cf7446605561 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:22:49 +0200 Subject: [PATCH 17/28] feat(datasets): disable dataset versions POST /datasets/{id}/versions/{version}/disable (API 1.101.0 or earlier) had no tw command, so a dataset version could only be disabled from the UI. - 'tw datasets disable-version --dataset-version ' disables it; the version can no longer be used for runs. - 'datasets view ... versions' gains a Disabled column (DatasetVersionDto.disabled), so the result is visible. DatasetsCmdTest now resets MockServer before each test, as the other suites do, so request verifications are not affected by earlier tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/DatasetsCmd.java | 2 + .../commands/datasets/DisableVersionCmd.java | 50 +++++++++++++++ .../datasets/DatasetVersionDisabled.java | 37 +++++++++++ .../datasets/DatasetVersionsList.java | 4 +- .../tower/cli/datasets/DatasetsCmdTest.java | 61 +++++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java diff --git a/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java b/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java index fbb568f46..fd931ee45 100644 --- a/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.cli.commands.datasets.AddCmd; import io.seqera.tower.cli.commands.datasets.DeleteCmd; +import io.seqera.tower.cli.commands.datasets.DisableVersionCmd; import io.seqera.tower.cli.commands.datasets.DownloadCmd; import io.seqera.tower.cli.commands.datasets.HideCmd; import io.seqera.tower.cli.commands.datasets.LabelsCmd; @@ -35,6 +36,7 @@ subcommands = { AddCmd.class, DeleteCmd.class, + DisableVersionCmd.class, DownloadCmd.class, HideCmd.class, LabelsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.java b/src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.java new file mode 100644 index 000000000..340b25bef --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.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.datasets; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.datasets.DatasetVersionDisabled; +import io.seqera.tower.model.DatasetDto; +import picocli.CommandLine; + +@CommandLine.Command( + name = "disable-version", + description = "Disable a dataset version so it can no longer be used for new runs. A disabled version cannot be enabled again." +) +public class DisableVersionCmd extends AbstractDatasetsCmd { + + @CommandLine.Mixin + public DatasetRefOptions datasetRefOptions; + + @CommandLine.Option(names = {"--dataset-version"}, description = "Dataset version to disable", required = true) + public Long version; + + @CommandLine.Mixin + public WorkspaceRequiredOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + DatasetDto dataset = fetchDescribeDatasetResponse(datasetRefOptions, wspId); + + datasetsApi().disableDatasetVersion(dataset.getId(), version, wspId); + + return new DatasetVersionDisabled(getDatasetRef(datasetRefOptions), version, workspace.workspace); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java new file mode 100644 index 000000000..6e7936bf4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java @@ -0,0 +1,37 @@ +/* + * 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.datasets; + +import io.seqera.tower.cli.responses.Response; + +public class DatasetVersionDisabled extends Response { + + public final String datasetRef; + public final Long version; + public final String workspaceRef; + + public DatasetVersionDisabled(String datasetRef, Long version, String workspaceRef) { + this.datasetRef = datasetRef; + this.version = version; + this.workspaceRef = workspaceRef; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Version %d of dataset '%s' disabled at %s workspace|@%n", version, datasetRef, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java index 2b77d8232..b978669a4 100644 --- a/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java +++ b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java @@ -44,9 +44,9 @@ public void toString(PrintWriter out) { return; } - TableList table = new TableList(out, 5, "Version", "Has Header", "Media Type", "File Name", "URL").sortBy(0); + TableList table = new TableList(out, 6, "Version", "Has Header", "Media Type", "File Name", "URL", "Disabled").sortBy(0); table.setPrefix(" "); - versions.forEach(v -> table.addRow(v.getVersion().toString(), v.getHasHeader().toString(), v.getMediaType(), v.getFileName(), v.getUrl())); + versions.forEach(v -> table.addRow(v.getVersion().toString(), v.getHasHeader().toString(), v.getMediaType(), v.getFileName(), v.getUrl(), String.valueOf(Boolean.TRUE.equals(v.getDisabled())))); table.print(); out.println(""); diff --git a/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java b/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java index 3a4f364ce..17ba47fdd 100644 --- a/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java @@ -26,14 +26,17 @@ import io.seqera.tower.cli.responses.datasets.DatasetList; import io.seqera.tower.cli.responses.datasets.DatasetUpdate; import io.seqera.tower.cli.responses.datasets.DatasetUrl; +import io.seqera.tower.cli.responses.datasets.DatasetVersionDisabled; import io.seqera.tower.cli.responses.datasets.DatasetVersionsList; import io.seqera.tower.cli.responses.datasets.DatasetView; import io.seqera.tower.cli.responses.datasets.DatasetsVisibility; import io.seqera.tower.cli.responses.labels.ManageLabels; import io.seqera.tower.model.DatasetDto; import io.seqera.tower.model.DatasetVersionDto; +import io.seqera.tower.model.ListDatasetVersionsResponse; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.api.BeforeEach; import org.mockserver.client.MockServerClient; import org.mockserver.model.JsonBody; import org.mockserver.model.MediaType; @@ -47,6 +50,7 @@ import static io.seqera.tower.cli.utils.JsonHelper.parseJson; 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; @@ -54,6 +58,11 @@ public class DatasetsCmdTest extends BaseCmdTest { + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + } + @ParameterizedTest @EnumSource(OutputType.class) void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { @@ -579,4 +588,56 @@ void testLabelsApply(OutputType format, MockServerClient mock) { assertEquals("", out.stdErr); assertEquals(0, out.exitCode); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisableVersion(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/metadata") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("datasets/dataset_metadata")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("POST").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/versions/2/disable") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "datasets", "disable-version", "-w", "249664655368293", "-i", "4D9TP0w2pM0qmwqVHgrgBK", "--dataset-version", "2"); + + assertOutput(format, out, new DatasetVersionDisabled("4D9TP0w2pM0qmwqVHgrgBK", 2L, "249664655368293")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testVersionsShowsDisabled(OutputType format, MockServerClient mock) throws JsonProcessingException { + String versions = """ + {"versions": [ + {"datasetId": "4D9TP0w2pM0qmwqVHgrgBK", "version": 1, "hasHeader": true, "mediaType": "text/csv", "disabled": true, + "fileName": "samples.csv", + "url": "https://api/workspaces/249664655368293/datasets/4D9TP0w2pM0qmwqVHgrgBK/v/1/n/samples.csv"} + ]} + """; + mock.when( + request().withMethod("GET").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/metadata") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("datasets/dataset_metadata")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/versions") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(versions).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "datasets", "view", "-w", "249664655368293", "-i", "4D9TP0w2pM0qmwqVHgrgBK", "versions"); + + assertOutput(format, out, new DatasetVersionsList(parseJson(versions, ListDatasetVersionsResponse.class).getVersions(), "4D9TP0w2pM0qmwqVHgrgBK", "249664655368293")); + if (format == OutputType.console) { + assertTrue(out.stdOut.contains("Disabled"), out.stdOut); + } + } } From 10d53218bc5f54379494b2bf3185651a715cc310 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:23:51 +0200 Subject: [PATCH 18/28] feat(teams): add view, update and workspaces commands The API has long had describeOrganizationTeam, updateOrganizationTeam and listWorkspacesByTeam (at least since 1.101.0), but tw could only list, add and delete teams. Add 'teams view', 'teams update' and 'teams workspaces', addressing a team by --id or --name. The update endpoint replaces name, description and avatar, so 'teams update' reads the team first and keeps unset fields. The team response only carries the avatar download URL, so the avatar ID is taken from its last path segment to avoid clearing the avatar. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/TeamsCmd.java | 6 ++ .../cli/commands/teams/AbstractTeamsCmd.java | 5 ++ .../cli/commands/teams/TeamRefOptions.java | 35 +++++++++ .../tower/cli/commands/teams/UpdateCmd.java | 66 ++++++++++++++++ .../tower/cli/commands/teams/ViewCmd.java | 43 +++++++++++ .../cli/commands/teams/WorkspacesCmd.java | 57 ++++++++++++++ .../cli/responses/teams/TeamUpdated.java | 35 +++++++++ .../tower/cli/responses/teams/TeamView.java | 47 ++++++++++++ .../responses/teams/TeamWorkspacesList.java | 64 ++++++++++++++++ .../seqera/tower/cli/teams/TeamsCmdTest.java | 76 +++++++++++++++++++ 10 files changed, 434 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/teams/TeamView.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java diff --git a/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java b/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java index 9424be4e4..3b2eb7f2f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java @@ -21,6 +21,9 @@ import io.seqera.tower.cli.commands.teams.DeleteCmd; import io.seqera.tower.cli.commands.teams.ListCmd; import io.seqera.tower.cli.commands.teams.MembersCmd; +import io.seqera.tower.cli.commands.teams.UpdateCmd; +import io.seqera.tower.cli.commands.teams.ViewCmd; +import io.seqera.tower.cli.commands.teams.WorkspacesCmd; import picocli.CommandLine; @CommandLine.Command( @@ -29,8 +32,11 @@ subcommands = { ListCmd.class, AddCmd.class, + ViewCmd.class, + UpdateCmd.class, DeleteCmd.class, MembersCmd.class, + WorkspacesCmd.class, } ) public class TeamsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java index b736603df..b2fbec7da 100644 --- a/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java @@ -66,6 +66,11 @@ public TeamDbDto findTeamByName(Long orgId, String teamName) throws ApiException .orElseThrow(() -> new TeamNotFoundException(orgId, teamName)); } + protected TeamDbDto fetchTeam(Long orgId, TeamRefOptions ref) throws ApiException { + Long teamId = ref.team.id != null ? ref.team.id : findTeamByName(orgId, ref.team.name).getTeamId(); + return teamsApi().describeOrganizationTeam(orgId, teamId).getTeam(); + } + public void deleteTeamById(Long teamId, String orgRef) throws OrganizationNotFoundException, ApiException { OrgAndWorkspaceDto orgAndWorkspaceDbDto = findOrganizationByRef(orgRef); deleteTeamById(teamId, orgAndWorkspaceDbDto.getOrgId()); diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java new file mode 100644 index 000000000..d5ea52187 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java @@ -0,0 +1,35 @@ +/* + * 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.teams; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class TeamRefOptions { + + @ArgGroup(multiplicity = "1") + public TeamRef team; + + public static class TeamRef { + + @Option(names = {"-i", "--id"}, description = "Team numeric identifier. Find team IDs using 'tw teams list'.") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Team name.") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java new file mode 100644 index 000000000..22da709d9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.teams; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.teams.TeamUpdated; +import io.seqera.tower.model.TeamDbDto; +import io.seqera.tower.model.UpdateTeamRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "update", + description = "Update a team" +) +public class UpdateCmd extends AbstractTeamsCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Mixin + TeamRefOptions ref; + + @Option(names = {"--new-name"}, description = "New team name. Must be unique within the organization.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "New team description.") + public String description; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + TeamDbDto team = fetchTeam(orgId, ref); + + // The API replaces name, description and avatar, so unset options keep the current values + UpdateTeamRequest request = new UpdateTeamRequest() + .name(newName != null ? newName : team.getName()) + .description(description != null ? description : team.getDescription()) + .avatarId(avatarId(team.getAvatarUrl())); + + teamsApi().updateOrganizationTeam(orgId, team.getTeamId(), request); + + return new TeamUpdated(organizationRef, request.getName()); + } + + // The team response only exposes the avatar download URL, which ends with the avatar ID + private static String avatarId(String avatarUrl) { + return avatarUrl == null ? null : avatarUrl.substring(avatarUrl.lastIndexOf('/') + 1); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java new file mode 100644 index 000000000..f5664118d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.teams; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.teams.TeamView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View team details" +) +public class ViewCmd extends AbstractTeamsCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Mixin + TeamRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new TeamView(organizationRef, fetchTeam(orgId, ref)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java new file mode 100644 index 000000000..4d1884feb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java @@ -0,0 +1,57 @@ +/* + * 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.teams; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.teams.TeamWorkspacesList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListWorkspacesByTeamResponse; +import io.seqera.tower.model.TeamDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "workspaces", + description = "List the workspaces a team participates in" +) +public class WorkspacesCmd extends AbstractTeamsCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Mixin + TeamRefOptions ref; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + TeamDbDto team = fetchTeam(orgId, ref); + + ListWorkspacesByTeamResponse response = teamsApi().listWorkspacesByTeam(orgId, team.getTeamId(), max, offset, null); + + return new TeamWorkspacesList(organizationRef, team.getName(), response.getWorkspaces(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java b/src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java new file mode 100644 index 000000000..d136a86e8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java @@ -0,0 +1,35 @@ +/* + * 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.teams; + +import io.seqera.tower.cli.responses.Response; + +public class TeamUpdated extends Response { + + public final String organizationName; + public final String teamName; + + public TeamUpdated(String organizationName, String teamName) { + this.organizationName = organizationName; + this.teamName = teamName; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Team '%s' updated at %s organization|@%n", teamName, organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/teams/TeamView.java b/src/main/java/io/seqera/tower/cli/responses/teams/TeamView.java new file mode 100644 index 000000000..6bfb7c580 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/teams/TeamView.java @@ -0,0 +1,47 @@ +/* + * 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.teams; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.TeamDbDto; + +import java.io.PrintWriter; + +public class TeamView extends Response { + + public final String organizationName; + public final TeamDbDto team; + + public TeamView(String organizationName, TeamDbDto team) { + this.organizationName = organizationName; + this.team = team; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Team '%s' at %s organization:|@%n", team.getName(), organizationName))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", team.getTeamId().toString()); + table.addRow("Name", team.getName()); + table.addRow("Description", team.getDescription()); + table.addRow("Members", team.getMembersCount() == null ? null : team.getMembersCount().toString()); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java b/src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java new file mode 100644 index 000000000..ff2508abf --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java @@ -0,0 +1,64 @@ +/* + * 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.teams; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.WorkspaceParticipantResponseDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +public class TeamWorkspacesList extends Response { + + public final String organizationName; + public final String teamName; + public final List workspaces; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public TeamWorkspacesList(String organizationName, String teamName, List workspaces, @Nullable PaginationInfo paginationInfo) { + this.organizationName = organizationName; + this.teamName = teamName; + this.workspaces = workspaces; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Workspaces of team '%s' at %s organization:|@%n", teamName, organizationName))); + + if (workspaces == null || workspaces.isEmpty()) { + out.println(ansi(" @|yellow No workspaces found|@")); + return; + } + + TableList table = new TableList(out, 3, "Workspace ID", "Workspace Name", "Role"); + table.setPrefix(" "); + workspaces.forEach(ws -> table.addRow(ws.getWorkspaceId().toString(), ws.getWorkspaceName(), ws.getParticipantRole())); + table.print(); + + PaginationInfo.addFooter(out, paginationInfo); + + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java b/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java index 2667d650e..28116771a 100644 --- a/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java @@ -23,13 +23,19 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.teams.TeamAdded; import io.seqera.tower.cli.responses.teams.TeamDeleted; +import io.seqera.tower.cli.responses.teams.TeamUpdated; +import io.seqera.tower.cli.responses.teams.TeamView; +import io.seqera.tower.cli.responses.teams.TeamWorkspacesList; import io.seqera.tower.cli.responses.teams.TeamsList; import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.DescribeTeamResponse; +import io.seqera.tower.model.ListWorkspacesByTeamResponse; import io.seqera.tower.model.TeamDbDto; 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.matchers.MatchType; import org.mockserver.model.MediaType; import java.util.Arrays; @@ -41,6 +47,7 @@ import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; class TeamsCmdTest extends BaseCmdTest { @@ -363,4 +370,73 @@ void testDeleteTeam(OutputType format, MockServerClient mock) { ExecOut out = exec(format, mock, "teams", "delete", "-o", "organization1", "-i", "69076469523589"); assertOutput(format, out, new TeamDeleted("organization1", "69076469523589")); } + + private static final String TEAM_PATH = "/orgs/27736513644467/teams/69076469523589"; + + private static final String TEAM = """ + {"team": {"teamId": 69076469523589, "name": "team-test-1", "description": "a new team", "membersCount": 2, + "avatarUrl": "https://tower.example/api/avatars/av123"}} + """; + + private void mockUserWorkspaces(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/user-info")) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces")) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockDescribeTeam(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath(TEAM_PATH), exactly(1)) + .respond(response().withStatusCode(200).withBody(TEAM).withContentType(MediaType.APPLICATION_JSON)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockUserWorkspaces(mock); + mock.when(request().withMethod("GET").withPath("/orgs/27736513644467/teams"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("teams/teams_list")).withContentType(MediaType.APPLICATION_JSON)); + mockDescribeTeam(mock); + + ExecOut out = exec(format, mock, "teams", "view", "-o", "organization1", "-n", "team-test-1"); + + assertOutput(format, out, new TeamView("organization1", parseJson(TEAM, DescribeTeamResponse.class).getTeam())); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdateKeepsUnsetFieldsAndAvatar(OutputType format, MockServerClient mock) { + mockUserWorkspaces(mock); + mockDescribeTeam(mock); + mock.when( + request().withMethod("PUT").withPath(TEAM_PATH) + .withBody(json(""" + {"name": "team-renamed", "description": "a new team", "avatarId": "av123"} + """, MatchType.STRICT)), exactly(1) + ).respond(response().withStatusCode(204)); + + ExecOut out = exec(format, mock, "teams", "update", "-o", "organization1", "-i", "69076469523589", "--new-name", "team-renamed"); + + assertOutput(format, out, new TeamUpdated("organization1", "team-renamed")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testWorkspaces(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockUserWorkspaces(mock); + mockDescribeTeam(mock); + String body = """ + {"workspaces": [{"orgId": 27736513644467, "orgName": "organization1", "workspaceId": 75887156211589, "workspaceName": "workspace1", + "workspaceFullName": "Workspace 1", "participantId": 9, "participantRole": "launch"}], "totalSize": 1} + """; + mock.when( + request().withMethod("GET").withPath(TEAM_PATH + "/workspaces") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1) + ).respond(response().withStatusCode(200).withBody(body).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "teams", "workspaces", "-o", "organization1", "-i", "69076469523589"); + + assertOutput(format, out, new TeamWorkspacesList("organization1", "team-test-1", parseJson(body, ListWorkspacesByTeamResponse.class).getWorkspaces(), null)); + } } From 00f1ce92f2de383176b83bb0f6dc7165e3d7bca2 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:24:05 +0200 Subject: [PATCH 19/28] fix(teams): find teams by name beyond the first page listOrganizationTeams pages by 50 by default, so looking a team up by name missed any team after the first page. That affects the new teams view/update/workspaces commands and the existing participants commands. Pass the name as the search filter and keep the exact-name match. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/commands/participants/AbstractParticipantsCmd.java | 2 +- .../io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java b/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java index 02750192a..cab5cd148 100644 --- a/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java @@ -71,7 +71,7 @@ protected MemberDbDto findOrganizationCollaboratorByName(Long orgId, String name } protected TeamDbDto findOrganizationTeamByName(Long orgId, String name) throws ApiException { - ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, null); + ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, name); if (listTeamResponse.getTeams() != null) { TeamDbDto team = listTeamResponse.getTeams().stream().filter(it -> Objects.equals(it.getName(), name)).findFirst().orElse(null); diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java index b2fbec7da..973d7ef64 100644 --- a/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java @@ -52,7 +52,7 @@ public MemberDbDto findMemberByUsername(Long orgId, Long teamId, String username } public TeamDbDto findTeamByName(Long orgId, String teamName) throws ApiException { - ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, null); + ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, teamName); if (listTeamResponse == null) { throw new TeamNotFoundException(orgId, teamName); From 973c412bf8e4651afd7f6996aca264db56cbcff6 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:24:35 +0200 Subject: [PATCH 20/28] feat(organizations): add quotas command describeOrganizationQuotas (/orgs/{orgId}/quotas, in the API since at least 1.101.0) had no tw command. Add 'tw organizations quotas' to show the organization limits. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/OrganizationsCmd.java | 2 + .../cli/commands/organizations/QuotasCmd.java | 40 ++++++++++++ .../organizations/OrganizationQuotasView.java | 63 +++++++++++++++++++ .../organizations/OrganizationsCmdTest.java | 22 +++++++ 4 files changed, 127 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java diff --git a/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java b/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java index ed8a99a6e..543a1d04a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.cli.commands.organizations.AddCmd; import io.seqera.tower.cli.commands.organizations.DeleteCmd; import io.seqera.tower.cli.commands.organizations.ListCmd; +import io.seqera.tower.cli.commands.organizations.QuotasCmd; import io.seqera.tower.cli.commands.organizations.UpdateCmd; import io.seqera.tower.cli.commands.organizations.ViewCmd; import picocli.CommandLine; @@ -32,6 +33,7 @@ AddCmd.class, UpdateCmd.class, ViewCmd.class, + QuotasCmd.class, } ) public class OrganizationsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java b/src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java new file mode 100644 index 000000000..2556f1553 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.organizations; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.organizations.OrganizationQuotasView; +import io.seqera.tower.model.OrganizationDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "quotas", + description = "View organization quotas" +) +public class QuotasCmd extends AbstractOrganizationsCmd { + + @Mixin + OrganizationRefOptions organizationRefOptions; + + @Override + protected Response exec() throws ApiException { + OrganizationDbDto organization = fetchOrganization(organizationRefOptions).getOrganization(); + return new OrganizationQuotasView(organization.getName(), orgsApi().describeOrganizationQuotas(organization.getOrgId(), null).getQuotas()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java b/src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java new file mode 100644 index 000000000..5dfc059f2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java @@ -0,0 +1,63 @@ +/* + * 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.organizations; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.OrganizationQuotas; + +import java.io.PrintWriter; +import java.util.Objects; + +public class OrganizationQuotasView extends Response { + + public final String organizationName; + public final OrganizationQuotas quotas; + + public OrganizationQuotasView(String organizationName, OrganizationQuotas quotas) { + this.organizationName = organizationName; + this.quotas = quotas; + } + + @Override + public Object getJSON() { + return quotas; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Quotas for %s organization:|@%n", organizationName))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("Workspaces", Objects.toString(quotas.getMaxWorkspaces(), null)); + table.addRow("Members", Objects.toString(quotas.getMaxMembers(), null)); + table.addRow("Teams", Objects.toString(quotas.getMaxTeams(), null)); + table.addRow("Custom roles", Objects.toString(quotas.getMaxCustomRolesPerOrg(), null)); + table.addRow("Participants per workspace", Objects.toString(quotas.getMaxParticipantsPerWorkspace(), null)); + table.addRow("Pipelines per workspace", Objects.toString(quotas.getMaxPipelinesPerWorkspace(), null)); + table.addRow("Datasets per workspace", Objects.toString(quotas.getMaxDatasetsPerWorkspace(), null)); + table.addRow("Versions per dataset", Objects.toString(quotas.getMaxVersionsPerDataset(), null)); + table.addRow("Labels per workspace", Objects.toString(quotas.getMaxLabelsPerWorkspace(), null)); + table.addRow("Runs", Objects.toString(quotas.getMaxRuns(), null)); + table.addRow("Run history", Objects.toString(quotas.getMaxRunHistory(), null)); + table.addRow("Running Studios", Objects.toString(quotas.getMaxDataStudiosRunning(), null)); + table.addRow("Seqera Compute environments", Objects.toString(quotas.getMaxSeqeraComputeComputeEnvs(), null)); + table.addRow("Fusion throughput (bytes)", Objects.toString(quotas.getMaxFusionThroughputBytes(), null)); + table.print(); + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java b/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java index e2838d569..12579cb84 100644 --- a/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java @@ -16,6 +16,8 @@ package io.seqera.tower.cli.organizations; +import io.seqera.tower.model.DescribeOrganizationQuotasResponse; +import io.seqera.tower.cli.responses.organizations.OrganizationQuotasView; import com.fasterxml.jackson.core.JsonProcessingException; import io.seqera.tower.cli.BaseCmdTest; import io.seqera.tower.cli.commands.enums.OutputType; @@ -405,4 +407,24 @@ void testUpdateError(MockServerClient mock) { assertEquals("", out.stdOut); assertEquals(1, out.exitCode); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testQuotas(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/user-info")) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces")) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/orgs/27736513644467"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("organizations/organizations_view")).withContentType(MediaType.APPLICATION_JSON)); + String body = """ + {"quotas": {"maxWorkspaces": 10, "maxMembers": 50, "maxTeams": 5, "maxCustomRolesPerOrg": 20, "maxRuns": 1000}} + """; + mock.when(request().withMethod("GET").withPath("/orgs/27736513644467/quotas"), exactly(1)) + .respond(response().withStatusCode(200).withBody(body).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "organizations", "quotas", "-n", "organization1"); + + assertOutput(format, out, new OrganizationQuotasView("organization1", parseJson(body, DescribeOrganizationQuotasResponse.class).getQuotas())); + } } From d9d8dec3a2b2cb8b519777d06ba44bc9cb74f390 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:24:57 +0200 Subject: [PATCH 21/28] feat(access-tokens): add access-tokens list, add and delete commands The /tokens endpoints (API 1.101) let users manage their personal access tokens, but tw could only consume one. 'tw access-tokens' now lists, creates and deletes them by id or name. 'add' prints the token value once, since Platform never returns it again. 'list' drops the deprecated basicAuth field, which is a usable credential for legacy tokens. DELETE /tokens/delete-all is not exposed: tw has no confirmation pattern for bulk destructive operations, and it would also revoke the token tw itself is running with. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/io/seqera/tower/cli/Tower.java | 2 + .../tower/cli/commands/AccessTokensCmd.java | 35 +++++ .../cli/commands/accesstokens/AddCmd.java | 43 ++++++ .../cli/commands/accesstokens/DeleteCmd.java | 57 ++++++++ .../cli/commands/accesstokens/ListCmd.java | 47 +++++++ .../AccessTokenNotFoundException.java | 25 ++++ .../accesstokens/AccessTokenAdded.java | 38 ++++++ .../accesstokens/AccessTokenDeleted.java | 36 +++++ .../accesstokens/AccessTokensList.java | 56 ++++++++ .../cli/accesstokens/AccessTokensCmdTest.java | 126 ++++++++++++++++++ 10 files changed, 465 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java create mode 100644 src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index 4cf2f248c..ffbb5796b 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -21,6 +21,7 @@ package io.seqera.tower.cli; import io.seqera.tower.cli.commands.AbstractCmd; +import io.seqera.tower.cli.commands.AccessTokensCmd; import io.seqera.tower.cli.commands.ActionsCmd; import io.seqera.tower.cli.commands.CollaboratorsCmd; import io.seqera.tower.cli.commands.ComputeEnvsCmd; @@ -56,6 +57,7 @@ name = "tw", description = "Seqera Platform CLI", subcommands = { + AccessTokensCmd.class, ActionsCmd.class, CollaboratorsCmd.class, ComputeEnvsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java b/src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java new file mode 100644 index 000000000..5c048e8b9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands; + +import io.seqera.tower.cli.commands.accesstokens.AddCmd; +import io.seqera.tower.cli.commands.accesstokens.DeleteCmd; +import io.seqera.tower.cli.commands.accesstokens.ListCmd; +import picocli.CommandLine.Command; + +@Command( + name = "access-tokens", + description = "Manage your personal access tokens", + subcommands = { + ListCmd.class, + AddCmd.class, + DeleteCmd.class, + } +) +public class AccessTokensCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java new file mode 100644 index 000000000..22fdf297c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.accesstokens; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenAdded; +import io.seqera.tower.model.CreateAccessTokenRequest; +import io.seqera.tower.model.CreateAccessTokenResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "add", + description = "Add a personal access token. The token value is shown only once." +) +public class AddCmd extends AbstractApiCmd { + + @Option(names = {"-n", "--name"}, description = "Token name. A label to remember what the token is for. Must be unique.", required = true) + public String name; + + @Override + protected Response exec() throws ApiException { + CreateAccessTokenResponse response = tokensApi().createToken(new CreateAccessTokenRequest().name(name)); + return new AccessTokenAdded(response.getToken().getId(), response.getToken().getName(), response.getAccessKey()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java new file mode 100644 index 000000000..f34a662a7 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java @@ -0,0 +1,57 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.AccessTokenNotFoundException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenDeleted; +import io.seqera.tower.model.AccessToken; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a personal access token. Clients using it can no longer authenticate." +) +public class DeleteCmd extends AbstractApiCmd { + + @ArgGroup(multiplicity = "1") + public TokenRef ref; + + public static class TokenRef { + + @Option(names = {"-i", "--id"}, description = "Token numeric identifier") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Token name") + public String name; + } + + @Override + protected Response exec() throws ApiException { + AccessToken token = tokensApi().tokenList().getTokens().stream() + .filter(it -> ref.id != null ? ref.id.equals(it.getId()) : ref.name.equals(it.getName())) + .findFirst() + .orElseThrow(() -> new AccessTokenNotFoundException(ref.id != null ? ref.id.toString() : ref.name)); + tokensApi().deleteToken(token.getId()); + return new AccessTokenDeleted(token.getId(), token.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.java new file mode 100644 index 000000000..604fb7326 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.java @@ -0,0 +1,47 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.accesstokens.AccessTokensList; +import io.seqera.tower.model.AccessToken; +import picocli.CommandLine.Command; + +import java.util.List; + +@Command( + name = "list", + description = "List your personal access tokens" +) +public class ListCmd extends AbstractApiCmd { + + @Override + protected Response exec() throws ApiException { + List tokens = tokensApi().tokenList().getTokens().stream() + // Drop the deprecated basicAuth field: it is a usable credential for legacy tokens + .map(token -> new AccessToken() + .id(token.getId()) + .name(token.getName()) + .dateCreated(token.getDateCreated()) + .lastUsed(token.getLastUsed())) + .toList(); + return new AccessTokensList(tokens); + } +} diff --git a/src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java b/src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java new file mode 100644 index 000000000..f4e69fb22 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java @@ -0,0 +1,25 @@ +/* + * 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; + +public class AccessTokenNotFoundException extends TowerException { + + public AccessTokenNotFoundException(String tokenRef) { + super(String.format("Unknown access token '%s'", tokenRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java new file mode 100644 index 000000000..b75546019 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java @@ -0,0 +1,38 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.responses.Response; + +public class AccessTokenAdded extends Response { + + public final Long id; + public final String name; + public final String accessToken; + + public AccessTokenAdded(Long id, String name, String accessToken) { + this.id = id; + this.name = name; + this.accessToken = accessToken; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New access token '%s' (%d) added.|@ Copy it now, it is not shown again:%n%n %s%n", name, id, accessToken)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.java b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.java new file mode 100644 index 000000000..b9d913914 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.java @@ -0,0 +1,36 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.responses.Response; + +public class AccessTokenDeleted extends Response { + + public final Long id; + public final String name; + + public AccessTokenDeleted(Long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Access token '%s' (%d) deleted|@%n", name, id)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java new file mode 100644 index 000000000..ec3886bb5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java @@ -0,0 +1,56 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AccessToken; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class AccessTokensList extends Response { + + public final List tokens; + + public AccessTokensList(List tokens) { + this.tokens = tokens; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Personal access tokens:|@%n"))); + if (tokens == null || tokens.isEmpty()) { + out.println(ansi(" @|yellow No access tokens found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Name", "Created", "Last used").sortBy(0); + table.setPrefix(" "); + tokens.forEach(token -> table.addRow( + token.getId().toString(), + token.getName(), + formatDate(token.getDateCreated()), + formatDate(token.getLastUsed()) + )); + table.print(); + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java b/src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java new file mode 100644 index 000000000..450c487d7 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java @@ -0,0 +1,126 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenAdded; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenDeleted; +import io.seqera.tower.cli.responses.accesstokens.AccessTokensList; +import io.seqera.tower.model.AccessToken; +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 java.io.IOException; +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; +import static org.mockserver.model.JsonBody.json; + +class AccessTokensCmdTest extends BaseCmdTest { + + private static final String TOKEN_1 = "{\"id\":21,\"name\":\"ci\",\"dateCreated\":\"2026-09-01T10:00:00Z\",\"lastUsed\":\"2026-09-02T10:00:00Z\"}"; + private static final String LEGACY_TOKEN = "{\"id\":22,\"name\":\"legacy\",\"basicAuth\":\"dG93ZXI6c2VjcmV0\",\"dateCreated\":\"2021-01-01T10:00:00Z\",\"lastUsed\":null}"; + private static final String LIST = "{\"tokens\":[" + TOKEN_1 + "," + LEGACY_TOKEN + "]}"; + + private void mockList(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/tokens"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(LIST).withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws IOException { + mockList(mock); + + ExecOut out = exec(format, mock, "access-tokens", "list"); + + assertOutput(format, out, new AccessTokensList(List.of( + parseJson(TOKEN_1, AccessToken.class), + parseJson("{\"id\":22,\"name\":\"legacy\",\"dateCreated\":\"2021-01-01T10:00:00Z\"}", AccessToken.class) + ))); + assertFalse(out.stdOut.contains("dG93ZXI6c2VjcmV0"), "basicAuth must never be printed"); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/tokens").withBody(json("{\"name\":\"ci\"}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"accessKey\":\"eyJ0aWQiOjIxfQ.secret\",\"token\":" + TOKEN_1 + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "access-tokens", "add", "-n", "ci"); + + assertOutput(format, out, new AccessTokenAdded(21L, "ci", "eyJ0aWQiOjIxfQ.secret")); + assertTrue(out.stdOut.contains("eyJ0aWQiOjIxfQ.secret")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteByName(OutputType format, MockServerClient mock) { + mockList(mock); + mock.when( + request().withMethod("DELETE").withPath("/tokens/22"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "access-tokens", "delete", "-n", "legacy"); + + assertOutput(format, out, new AccessTokenDeleted(22L, "legacy")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteById(OutputType format, MockServerClient mock) { + mockList(mock); + mock.when( + request().withMethod("DELETE").withPath("/tokens/21"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "access-tokens", "delete", "-i", "21"); + + assertOutput(format, out, new AccessTokenDeleted(21L, "ci")); + } + + @Test + void testDeleteUnknownId(MockServerClient mock) { + mockList(mock); + + ExecOut out = exec(mock, "access-tokens", "delete", "-i", "99"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Unknown access token '99'"), out.stdErr); + } +} From 654df09e9fb7240f88bbf43e64de80f0e62b7529 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:26:04 +0200 Subject: [PATCH 22/28] feat(managed-identities): add managed identities and their SSH credentials Managed identities (/identities, API 1.101) are organization-level HPC cluster connections (Altair PBS, LSF, Moab, Slurm, UGE): an owner registers the login host and port once, and each member attaches their own SSH key and Linux username as managed credentials. tw could not manage either. 'tw managed-identities' adds list/add/view/update/delete, and 'tw managed-identities credentials' lists members' credentials and adds, replaces or deletes them, for yourself or (as owner) for another member. Deletions honour Platform's running-jobs check unless --force. Two SDK gaps are worked around: the describe endpoint is modelled with the wrong response shape, so identities are looked up in the list; and the managed credentials metadata model lacks the required SSH userName, so tw sends it through a small subclass. SDK 1.200.0 generates a separate conflict type for each 409 response, so the managed credentials 409 body is parsed as the credentials one (same shape) to reuse CredentialsInUseException. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/io/seqera/tower/cli/Tower.java | 2 + .../tower/cli/commands/AbstractApiCmd.java | 6 + .../cli/commands/ManagedIdentitiesCmd.java | 41 +++ .../AbstractManagedIdentitiesCmd.java | 86 ++++++ .../commands/managedidentities/AddCmd.java | 59 ++++ .../managedidentities/CredentialsCmd.java | 107 +++++++ .../commands/managedidentities/DeleteCmd.java | 50 +++ .../commands/managedidentities/ListCmd.java | 40 +++ .../ManagedIdentityPlatform.java | 65 ++++ .../ManagedIdentityRefOptions.java | 40 +++ .../SshManagedCredentialsMetadata.java | 35 +++ .../commands/managedidentities/UpdateCmd.java | 73 +++++ .../commands/managedidentities/ViewCmd.java | 44 +++ .../managedidentities/credentials/AddCmd.java | 60 ++++ .../credentials/DeleteCmd.java | 57 ++++ .../credentials/SshCredentialsOptions.java | 43 +++ .../credentials/UpdateCmd.java | 60 ++++ .../ManagedIdentityNotFoundException.java | 25 ++ .../ManagedCredentialsAdded.java | 36 +++ .../ManagedCredentialsDeleted.java | 36 +++ .../ManagedCredentialsList.java | 58 ++++ .../ManagedCredentialsUpdated.java | 36 +++ .../ManagedIdentitiesList.java | 56 ++++ .../ManagedIdentityAdded.java | 38 +++ .../ManagedIdentityDeleted.java | 38 +++ .../ManagedIdentityUpdated.java | 38 +++ .../ManagedIdentityView.java | 51 ++++ .../ManagedIdentitiesCmdTest.java | 285 ++++++++++++++++++ 28 files changed, 1565 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java create mode 100644 src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index ffbb5796b..3b3eeee57 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -31,6 +31,7 @@ import io.seqera.tower.cli.commands.DatasetsCmd; import io.seqera.tower.cli.commands.InfoCmd; import io.seqera.tower.cli.commands.LaunchCmd; +import io.seqera.tower.cli.commands.ManagedIdentitiesCmd; import io.seqera.tower.cli.commands.MembersCmd; import io.seqera.tower.cli.commands.OrganizationsCmd; import io.seqera.tower.cli.commands.ParticipantsCmd; @@ -69,6 +70,7 @@ InfoCmd.class, LabelsCmd.class, LaunchCmd.class, + ManagedIdentitiesCmd.class, MembersCmd.class, OrganizationsCmd.class, ParticipantsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index dcbde8236..0eef44813 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -25,6 +25,7 @@ import io.seqera.tower.api.DataLinksApi; import io.seqera.tower.api.DatasetsApi; import io.seqera.tower.api.Ga4ghApi; +import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; import io.seqera.tower.api.OrgsApi; @@ -122,6 +123,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private DataLinksApi dataLinksApi; private DatasetsApi datasetsApi; private Ga4ghApi ga4ghApi; + private IdentitiesApi identitiesApi; private LabelsApi labelsApi; private LaunchApi launchApi; private OrgsApi orgsApi; @@ -224,6 +226,10 @@ protected Ga4ghApi ga4ghApi() throws ApiException { return ga4ghApi == null ? new Ga4ghApi(apiClient()) : ga4ghApi; } + protected IdentitiesApi identitiesApi() throws ApiException { + return identitiesApi == null ? new IdentitiesApi(apiClient()) : identitiesApi; + } + protected LabelsApi labelsApi() throws ApiException { return labelsApi == null ? new LabelsApi(apiClient()) : labelsApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java b/src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java new file mode 100644 index 000000000..61dbeca0c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands; + +import io.seqera.tower.cli.commands.managedidentities.AddCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.commands.managedidentities.DeleteCmd; +import io.seqera.tower.cli.commands.managedidentities.ListCmd; +import io.seqera.tower.cli.commands.managedidentities.UpdateCmd; +import io.seqera.tower.cli.commands.managedidentities.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "managed-identities", + description = "Manage organization managed identities for HPC clusters", + subcommands = { + ListCmd.class, + AddCmd.class, + ViewCmd.class, + UpdateCmd.class, + DeleteCmd.class, + CredentialsCmd.class, + } +) +public class ManagedIdentitiesCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java new file mode 100644 index 000000000..7e732837c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java @@ -0,0 +1,86 @@ +/* + * 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.managedidentities; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.CredentialsInUseException; +import io.seqera.tower.cli.exceptions.ManagedIdentityNotFoundException; +import io.seqera.tower.model.DeleteCredentialsConflictResponse; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; + +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; + +@Command +public abstract class AbstractManagedIdentitiesCmd extends AbstractApiCmd { + + protected static final String ORGANIZATION_DESCRIPTION = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'."; + + protected List listManagedIdentities(Long orgId) throws ApiException { + return identitiesApi().listManagedIdentities(orgId, null, null, null).getManagedIdentities(); + } + + // The describe endpoint response is not modelled correctly in the SDK (it wraps the identity in a + // 'managedIdentity' field), so look the identity up in the organization list instead. + protected ManagedIdentityDbDtoAbstractGridConfig findManagedIdentity(Long orgId, ManagedIdentityRefOptions ref) throws ApiException { + return listManagedIdentities(orgId).stream() + .filter(it -> ref.managedIdentity.id != null ? ref.managedIdentity.id.equals(it.getId()) : ref.managedIdentity.name.equals(it.getName())) + .findFirst() + .orElseThrow(() -> new ManagedIdentityNotFoundException(ref.ref(), orgId)); + } + + /** + * Runs a managed identity or managed credentials deletion, turning the 409 the Platform returns when running + * jobs use the credentials into a {@link CredentialsInUseException}. + */ + protected static void deleteUnlessInUse(String ref, DeleteCall call) throws ApiException { + try { + call.run(); + } catch (ApiException e) { + DeleteCredentialsConflictResponse conflict = decodeConflict(e); + if (conflict != null) { + throw new CredentialsInUseException(ref, conflict.getConflicts()); + } + throw e; + } + } + + // The managed credentials 409 body has the same shape as the credentials one, whose conflict type + // CredentialsInUseException reports. + private static DeleteCredentialsConflictResponse decodeConflict(ApiException e) { + if (e.getCode() != 409 || e.getResponseBody() == null) { + return null; + } + try { + DeleteCredentialsConflictResponse conflict = parseJson(e.getResponseBody(), DeleteCredentialsConflictResponse.class); + return conflict.getConflicts() == null || conflict.getConflicts().isEmpty() ? null : conflict; + } catch (JsonProcessingException ignored) { + // Not a conflict body: let the original error through + return null; + } + } + + @FunctionalInterface + protected interface DeleteCall { + void run() throws ApiException; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.java new file mode 100644 index 000000000..5bdf9d690 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.java @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityAdded; +import io.seqera.tower.model.CreateManagedIdentityRequest; +import io.seqera.tower.model.CreateManagedIdentityResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "add", + description = "Add a managed identity for an HPC cluster. Each organization member then adds their own SSH credentials with 'tw managed-identities credentials add'." +) +public class AddCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Cluster name. Must be unique per organization. Names consist of alphanumeric, hyphen, and underscore characters.", required = true) + public String name; + + @Option(names = {"-p", "--platform"}, description = "Cluster workload manager: ${COMPLETION-CANDIDATES}.", required = true) + public ManagedIdentityPlatform platform; + + @Option(names = {"-H", "--host-name"}, description = "Hostname of the cluster to connect to via SSH, usually the login node. Must be a fully qualified hostname, not a local IP address.", required = true) + public String hostName; + + @Option(names = {"--port"}, description = "SSH port for the login connection [default: 22].", defaultValue = "22") + public Integer port; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + CreateManagedIdentityRequest request = new CreateManagedIdentityRequest() + .name(name) + .platform(CreateManagedIdentityRequest.PlatformEnum.fromValue(platform.value)) + .config(platform.config(hostName, port)); + CreateManagedIdentityResponse response = identitiesApi().createManagedIdentity(request, orgId); + return new ManagedIdentityAdded(organizationRef, response.getId(), response.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java new file mode 100644 index 000000000..cd4442d0d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java @@ -0,0 +1,107 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.managedidentities.credentials.AddCmd; +import io.seqera.tower.cli.commands.managedidentities.credentials.DeleteCmd; +import io.seqera.tower.cli.commands.managedidentities.credentials.UpdateCmd; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsList; +import io.seqera.tower.model.CreateManagedCredentialsRequest; +import io.seqera.tower.model.Credentials; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import io.seqera.tower.model.SSHSecurityKeys; +import io.seqera.tower.model.UpdateManagedCredentialsRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.util.List; + +@Command( + name = "credentials", + description = "List the organization members' SSH credentials for a managed identity", + subcommands = { + AddCmd.class, + UpdateCmd.class, + DeleteCmd.class, + } +) +public class CredentialsCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + public ManagedIdentityRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = findManagedIdentity(orgId, ref); + return new ManagedCredentialsList(organizationRef, identity.getName(), listManagedCredentials(orgId, identity.getId(), null)); + } + + public Long orgId() throws ApiException { + return findOrganizationByRef(organizationRef).getOrgId(); + } + + public ManagedIdentityDbDtoAbstractGridConfig managedIdentity(Long orgId) throws ApiException { + return findManagedIdentity(orgId, ref); + } + + /** + * Organization owners get a row for every member, with an empty managed credentials id when the member has + * not added credentials yet. Other members only get their own row. + */ + public ListManagedCredentialsRespDto memberRow(Long orgId, ManagedIdentityDbDtoAbstractGridConfig identity, String member) throws ApiException { + return listManagedCredentials(orgId, identity.getId(), member).stream() + .filter(it -> member.equals(it.getUserName())) + .findFirst() + .orElseThrow(() -> new TowerException(String.format("Member '%s' not found in the organization", member))); + } + + public ListManagedCredentialsRespDto memberCredentials(Long orgId, ManagedIdentityDbDtoAbstractGridConfig identity, String member) throws ApiException { + String userName = member != null ? member : userName(); + return listManagedCredentials(orgId, identity.getId(), userName).stream() + .filter(it -> userName.equals(it.getUserName()) && it.getManagedCredentialsId() != null) + .findFirst() + .orElseThrow(() -> new TowerException(String.format("Member '%s' has no credentials for managed identity '%s'", userName, identity.getName()))); + } + + private List listManagedCredentials(Long orgId, Long identityId, String search) throws ApiException { + return identitiesApi().listManagedCredentials(identityId, orgId, null, search, null, null).getManagedCredentials(); + } + + public static CreateManagedCredentialsRequest createRequest(SSHSecurityKeys keys, String linuxUserName) { + return new CreateManagedCredentialsRequest() + .provider(CreateManagedCredentialsRequest.ProviderEnum.SSH) + .credentials(new Credentials().provider(Credentials.ProviderEnum.SSH).keys(keys)) + .metadata(new SshManagedCredentialsMetadata(linuxUserName)); + } + + public static UpdateManagedCredentialsRequest updateRequest(SSHSecurityKeys keys, String linuxUserName) { + return new UpdateManagedCredentialsRequest() + .provider(UpdateManagedCredentialsRequest.ProviderEnum.SSH) + .credentials(new Credentials().provider(Credentials.ProviderEnum.SSH).keys(keys)) + .metadata(new SshManagedCredentialsMetadata(linuxUserName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.java new file mode 100644 index 000000000..bb1b91999 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityDeleted; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a managed identity and all its members' credentials. Compute environments that use it become invalid." +) +public class DeleteCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ManagedIdentityRefOptions ref; + + @Option(names = {"--force"}, description = "Delete the managed identity even if running jobs use its credentials. Those jobs are stopped. By default, a managed identity in use is not deleted.") + public boolean force = false; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = findManagedIdentity(orgId, ref); + deleteUnlessInUse(identity.getName(), () -> identitiesApi().deleteManagedIdentity(identity.getId(), orgId, !force)); + return new ManagedIdentityDeleted(organizationRef, identity.getId(), identity.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java new file mode 100644 index 000000000..9e37cc1c8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentitiesList; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List organization managed identities" +) +public class ListCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new ManagedIdentitiesList(organizationRef, listManagedIdentities(orgId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java new file mode 100644 index 000000000..2dd0934c1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java @@ -0,0 +1,65 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.model.AbstractGridConfig; +import io.seqera.tower.model.AltairPbsComputeConfig; +import io.seqera.tower.model.LsfComputeConfig; +import io.seqera.tower.model.MoabComputeConfig; +import io.seqera.tower.model.SlurmComputeConfig; +import io.seqera.tower.model.UnivaComputeConfig; + +import java.util.function.Supplier; + +/** + * HPC schedulers a managed identity can target, named like the 'tw compute-envs add' subcommands. + */ +public enum ManagedIdentityPlatform { + altair("altair-platform", AltairPbsComputeConfig::new), + lsf("lsf-platform", LsfComputeConfig::new), + moab("moab-platform", MoabComputeConfig::new), + slurm("slurm-platform", SlurmComputeConfig::new), + uge("uge-platform", UnivaComputeConfig::new); + + public final String value; + private final Supplier configFactory; + + ManagedIdentityPlatform(String value, Supplier configFactory) { + this.value = value; + this.configFactory = configFactory; + } + + /** + * Platform reads the config subtype from its discriminator, so it must be the platform's own config class. + */ + public AbstractGridConfig config(String hostName, Integer port) { + AbstractGridConfig config = configFactory.get(); + config.setHostName(hostName); + config.setPort(port); + return config; + } + + public static ManagedIdentityPlatform fromValue(String value) { + for (ManagedIdentityPlatform platform : values()) { + if (platform.value.equals(value)) { + return platform; + } + } + throw new IllegalArgumentException(String.format("Unsupported managed identity platform '%s'", value)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java new file mode 100644 index 000000000..087c7b2a0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class ManagedIdentityRefOptions { + + @ArgGroup(multiplicity = "1") + public ManagedIdentityRef managedIdentity; + + public static class ManagedIdentityRef { + + @Option(names = {"-i", "--id"}, description = "Managed identity numeric identifier") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Managed identity name") + public String name; + } + + public String ref() { + return managedIdentity.id != null ? managedIdentity.id.toString() : managedIdentity.name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java new file mode 100644 index 000000000..ca437387f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java @@ -0,0 +1,35 @@ +/* + * 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.managedidentities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.seqera.tower.model.ManagedCredentialsMetadata; + +/** + * The SDK models managed credentials metadata without the SSH 'userName' field that Platform requires + * (it only knows the read-only discriminator), so add it here. + */ +public class SshManagedCredentialsMetadata extends ManagedCredentialsMetadata { + + @JsonProperty("userName") + public final String userName; + + public SshManagedCredentialsMetadata(String userName) { + this.userName = userName; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java new file mode 100644 index 000000000..a457fe2c1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java @@ -0,0 +1,73 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityUpdated; +import io.seqera.tower.model.AbstractGridConfig; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import io.seqera.tower.model.UpdateManagedIdentityRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "update", + description = "Update a managed identity. Changing the host of a cluster used by compute environments may break them." +) +public class UpdateCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ManagedIdentityRefOptions ref; + + @Option(names = {"--new-name"}, description = "New cluster name. Must be unique per organization.") + public String newName; + + @Option(names = {"-H", "--host-name"}, description = "New hostname of the cluster to connect to via SSH.") + public String hostName; + + @Option(names = {"--port"}, description = "New SSH port for the login connection.") + public Integer port; + + @Override + protected Response exec() throws ApiException { + if (newName == null && hostName == null && port == null) { + throw new TowerException("Nothing to update: provide at least one of '--new-name', '--host-name' or '--port'"); + } + + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ManagedIdentityDbDtoAbstractGridConfig current = findManagedIdentity(orgId, ref); + AbstractGridConfig currentConfig = current.getConfig(); + + // Platform only applies the name, host and port, but needs the platform to read the config + ManagedIdentityDbDtoAbstractGridConfig update = new ManagedIdentityDbDtoAbstractGridConfig() + .name(newName != null ? newName : current.getName()) + .platform(current.getPlatform()) + .config(ManagedIdentityPlatform.fromValue(current.getPlatform().getValue()).config( + hostName != null ? hostName : currentConfig.getHostName(), + port != null ? port : currentConfig.getPort())); + identitiesApi().updateManagedIdentity(current.getId(), new UpdateManagedIdentityRequest().managedIdentity(update), orgId); + + return new ManagedIdentityUpdated(organizationRef, current.getId(), update.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java new file mode 100644 index 000000000..4ad564637 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View managed identity details" +) +public class ViewCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ManagedIdentityRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new ManagedIdentityView(organizationRef, findManagedIdentity(orgId, ref)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java new file mode 100644 index 000000000..ffc0c9b49 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded; +import io.seqera.tower.model.CreateManagedCredentialsResponse; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; + +@Command( + name = "add", + description = "Add SSH credentials to a managed identity" +) +public class AddCmd extends AbstractApiCmd { + + @ParentCommand + public CredentialsCmd parent; + + @Option(names = {"-m", "--member"}, description = "Platform username of the organization member the credentials belong to. Only organization owners can add credentials for other members [default: you].") + public String member; + + @Mixin + public SshCredentialsOptions ssh; + + @Override + protected Response exec() throws ApiException, IOException { + Long orgId = parent.orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = parent.managedIdentity(orgId); + Long userId = member != null ? parent.memberRow(orgId, identity, member).getUserId() : null; + + CreateManagedCredentialsResponse response = identitiesApi().createManagedCredentials( + identity.getId(), CredentialsCmd.createRequest(ssh.securityKeys(), ssh.linuxUserName), orgId, userId); + + return new ManagedCredentialsAdded(identity.getName(), response.getManagedCredentials().getId()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java new file mode 100644 index 000000000..0be74c594 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java @@ -0,0 +1,57 @@ +/* + * 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.managedidentities.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.managedidentities.AbstractManagedIdentitiesCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsDeleted; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +@Command( + name = "delete", + description = "Delete the SSH credentials of an organization member from a managed identity" +) +public class DeleteCmd extends AbstractManagedIdentitiesCmd { + + @ParentCommand + public CredentialsCmd parent; + + @Option(names = {"-m", "--member"}, description = "Platform username of the organization member the credentials belong to. Only organization owners can delete credentials of other members [default: you].") + public String member; + + @Option(names = {"--force"}, description = "Delete the credentials even if running jobs use them. Those jobs are stopped. By default, credentials in use are not deleted.") + public boolean force = false; + + @Override + protected Response exec() throws ApiException { + Long orgId = parent.orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = parent.managedIdentity(orgId); + ListManagedCredentialsRespDto credentials = parent.memberCredentials(orgId, identity, member); + Long id = credentials.getManagedCredentialsId(); + + deleteUnlessInUse(id.toString(), () -> identitiesApi().deleteManagedCredentials(identity.getId(), id, orgId, !force)); + + return new ManagedCredentialsDeleted(identity.getName(), id); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java new file mode 100644 index 000000000..8f04826b6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities.credentials; + +import io.seqera.tower.cli.utils.FilesHelper; +import io.seqera.tower.model.SSHSecurityKeys; +import picocli.CommandLine.Option; + +import java.io.IOException; +import java.nio.file.Path; + +public class SshCredentialsOptions { + + @Option(names = {"-l", "--linux-username"}, description = "Linux username of the organization member on the cluster.", required = true) + public String linuxUserName; + + @Option(names = {"-k", "--key"}, description = "Path to the SSH private key file used to connect to the cluster.", required = true) + public Path privateKey; + + @Option(names = {"-p", "--passphrase"}, description = "Passphrase for an encrypted SSH private key.", arity = "0..1", interactive = true) + public String passphrase; + + public SSHSecurityKeys securityKeys() throws IOException { + return new SSHSecurityKeys() + .privateKey(FilesHelper.readString(privateKey)) + .passphrase(passphrase); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java new file mode 100644 index 000000000..3ff83664c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.managedidentities.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsUpdated; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; + +@Command( + name = "update", + description = "Replace the SSH credentials of an organization member in a managed identity" +) +public class UpdateCmd extends AbstractApiCmd { + + @ParentCommand + public CredentialsCmd parent; + + @Option(names = {"-m", "--member"}, description = "Platform username of the organization member the credentials belong to. Only organization owners can update credentials of other members [default: you].") + public String member; + + @Mixin + public SshCredentialsOptions ssh; + + @Override + protected Response exec() throws ApiException, IOException { + Long orgId = parent.orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = parent.managedIdentity(orgId); + ListManagedCredentialsRespDto credentials = parent.memberCredentials(orgId, identity, member); + + identitiesApi().updateManagedCredentials( + identity.getId(), credentials.getManagedCredentialsId(), CredentialsCmd.updateRequest(ssh.securityKeys(), ssh.linuxUserName), orgId); + + return new ManagedCredentialsUpdated(identity.getName(), credentials.getManagedCredentialsId()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java b/src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java new file mode 100644 index 000000000..f0bf8beea --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java @@ -0,0 +1,25 @@ +/* + * 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; + +public class ManagedIdentityNotFoundException extends TowerException { + + public ManagedIdentityNotFoundException(String managedIdentityRef, Long organizationId) { + super(String.format("Managed identity '%s' not found in organization '%d'", managedIdentityRef, organizationId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.java new file mode 100644 index 000000000..ab3cdd601 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.java @@ -0,0 +1,36 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedCredentialsAdded extends Response { + + public final String managedIdentityName; + public final Long managedCredentialsId; + + public ManagedCredentialsAdded(String managedIdentityName, Long managedCredentialsId) { + this.managedIdentityName = managedIdentityName; + this.managedCredentialsId = managedCredentialsId; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New SSH credentials (%d) added to managed identity '%s'|@%n", managedCredentialsId, managedIdentityName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.java new file mode 100644 index 000000000..ceaea9a8f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.java @@ -0,0 +1,36 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedCredentialsDeleted extends Response { + + public final String managedIdentityName; + public final Long managedCredentialsId; + + public ManagedCredentialsDeleted(String managedIdentityName, Long managedCredentialsId) { + this.managedIdentityName = managedIdentityName; + this.managedCredentialsId = managedCredentialsId; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow SSH credentials (%d) deleted from managed identity '%s'|@%n", managedCredentialsId, managedIdentityName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java new file mode 100644 index 000000000..34dde25dc --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java @@ -0,0 +1,58 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ListManagedCredentialsRespDto; + +import java.io.PrintWriter; +import java.util.List; + +public class ManagedCredentialsList extends Response { + + public final String organizationRef; + public final String managedIdentityName; + public final List managedCredentials; + + public ManagedCredentialsList(String organizationRef, String managedIdentityName, List managedCredentials) { + this.organizationRef = organizationRef; + this.managedIdentityName = managedIdentityName; + this.managedCredentials = managedCredentials; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Credentials of managed identity '%s' at %s organization:|@%n", managedIdentityName, organizationRef))); + if (managedCredentials == null || managedCredentials.isEmpty()) { + out.println(ansi(" @|yellow No credentials found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Member", "Name", "Status"); + table.setPrefix(" "); + managedCredentials.forEach(it -> table.addRow( + it.getManagedCredentialsId() != null ? it.getManagedCredentialsId().toString() : "", + it.getUserName(), + String.join(" ", it.getFirstName() != null ? it.getFirstName() : "", it.getLastName() != null ? it.getLastName() : "").strip(), + it.getManagedCredentialsId() != null ? "added" : "missing" + )); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.java new file mode 100644 index 000000000..d5a73d1c0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.java @@ -0,0 +1,36 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedCredentialsUpdated extends Response { + + public final String managedIdentityName; + public final Long managedCredentialsId; + + public ManagedCredentialsUpdated(String managedIdentityName, Long managedCredentialsId) { + this.managedIdentityName = managedIdentityName; + this.managedCredentialsId = managedCredentialsId; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow SSH credentials (%d) updated in managed identity '%s'|@%n", managedCredentialsId, managedIdentityName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java new file mode 100644 index 000000000..2a528dcfb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java @@ -0,0 +1,56 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; + +import java.io.PrintWriter; +import java.util.List; + +public class ManagedIdentitiesList extends Response { + + public final String organizationRef; + public final List managedIdentities; + + public ManagedIdentitiesList(String organizationRef, List managedIdentities) { + this.organizationRef = organizationRef; + this.managedIdentities = managedIdentities; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Managed identities at %s organization:|@%n", organizationRef))); + if (managedIdentities == null || managedIdentities.isEmpty()) { + out.println(ansi(" @|yellow No managed identities found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Name", "Platform", "Host").sortBy(0); + table.setPrefix(" "); + managedIdentities.forEach(it -> table.addRow( + it.getId().toString(), + it.getName(), + it.getPlatform() != null ? it.getPlatform().getValue() : "", + it.getConfig() != null ? String.format("%s:%s", it.getConfig().getHostName(), it.getConfig().getPort()) : "" + )); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java new file mode 100644 index 000000000..ddb92528c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java @@ -0,0 +1,38 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedIdentityAdded extends Response { + + public final String organizationRef; + public final Long id; + public final String name; + + public ManagedIdentityAdded(String organizationRef, Long id, String name) { + this.organizationRef = organizationRef; + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New managed identity '%s' (%d) added at %s organization|@%n", name, id, organizationRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java new file mode 100644 index 000000000..14692936a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java @@ -0,0 +1,38 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedIdentityDeleted extends Response { + + public final String organizationRef; + public final Long id; + public final String name; + + public ManagedIdentityDeleted(String organizationRef, Long id, String name) { + this.organizationRef = organizationRef; + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Managed identity '%s' (%d) deleted at %s organization|@%n", name, id, organizationRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java new file mode 100644 index 000000000..d5a7751f6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java @@ -0,0 +1,38 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedIdentityUpdated extends Response { + + public final String organizationRef; + public final Long id; + public final String name; + + public ManagedIdentityUpdated(String organizationRef, Long id, String name) { + this.organizationRef = organizationRef; + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Managed identity '%s' (%d) updated at %s organization|@%n", name, id, organizationRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java new file mode 100644 index 000000000..4144991f1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java @@ -0,0 +1,51 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; + +import java.io.PrintWriter; + +public class ManagedIdentityView extends Response { + + public final String organizationRef; + public final ManagedIdentityDbDtoAbstractGridConfig managedIdentity; + + public ManagedIdentityView(String organizationRef, ManagedIdentityDbDtoAbstractGridConfig managedIdentity) { + this.organizationRef = organizationRef; + this.managedIdentity = managedIdentity; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Managed identity at %s organization:|@%n", organizationRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", managedIdentity.getId().toString()); + table.addRow("Name", managedIdentity.getName()); + table.addRow("Platform", managedIdentity.getPlatform() != null ? managedIdentity.getPlatform().getValue() : ""); + if (managedIdentity.getConfig() != null) { + table.addRow("Host name", managedIdentity.getConfig().getHostName()); + table.addRow("Port", String.valueOf(managedIdentity.getConfig().getPort())); + } + table.print(); + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java b/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java new file mode 100644 index 000000000..05e4880fb --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java @@ -0,0 +1,285 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsDeleted; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsList; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsUpdated; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentitiesList; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityAdded; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityDeleted; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityUpdated; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityView; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import org.junit.jupiter.api.BeforeEach; +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.matchers.MatchType; +import org.mockserver.model.MediaType; + +import java.io.IOException; +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +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; +import static org.mockserver.model.JsonBody.json; + +class ManagedIdentitiesCmdTest extends BaseCmdTest { + + private static final String ORG_ID = "27736513644467"; + private static final String IDENTITY = "{\"id\":31,\"name\":\"hpc1\",\"platform\":\"slurm-platform\",\"config\":{\"discriminator\":\"slurm-platform\",\"hostName\":\"login.example.com\",\"port\":22}}"; + private static final String IDENTITIES = "{\"managedIdentities\":[" + IDENTITY + ",{\"id\":32,\"name\":\"hpc2\",\"platform\":\"lsf-platform\",\"config\":{\"discriminator\":\"lsf-platform\",\"hostName\":\"lsf.example.com\",\"port\":2222}}],\"totalSize\":2}"; + private static final String OWN_ROW = "{\"managedCredentialsId\":41,\"userId\":1264,\"userName\":\"jordi\",\"provider\":\"ssh\",\"metadata\":{\"discriminator\":\"ssh\",\"userName\":\"jordi_hpc\"}}"; + private static final String MISSING_ROW = "{\"managedCredentialsId\":null,\"userId\":77,\"userName\":\"alice\",\"firstName\":\"Alice\",\"lastName\":\"Doe\"}"; + private static final String CREDENTIALS = "{\"managedCredentials\":[" + OWN_ROW + "," + MISSING_ROW + "],\"totalSize\":2}"; + + @BeforeEach + void mockOrganization(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/user-info") + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/user/1264/workspaces") + ).respond( + response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockIdentities(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/identities").withQueryStringParameter("orgId", ORG_ID), exactly(1) + ).respond( + response().withStatusCode(200).withBody(IDENTITIES).withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockCredentials(MockServerClient mock, String search) { + mock.when( + search == null + ? request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID) + : request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("search", search), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(CREDENTIALS).withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + + ExecOut out = exec(format, mock, "managed-identities", "list", "-o", "organization1"); + + assertOutput(format, out, new ManagedIdentitiesList("organization1", + parseJson(IDENTITIES, io.seqera.tower.model.ListManagedIdentitiesResponse.class).getManagedIdentities())); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/identities").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"name\":\"hpc1\",\"platform\":\"slurm-platform\",\"config\":{\"discriminator\":\"slurm-platform\",\"hostName\":\"login.example.com\",\"port\":22}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(IDENTITY).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "managed-identities", "add", "-o", "organization1", "-n", "hpc1", "-p", "slurm", "-H", "login.example.com"); + + assertOutput(format, out, new ManagedIdentityAdded("organization1", 31L, "hpc1")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + + ExecOut out = exec(format, mock, "managed-identities", "view", "-o", "organization1", "-n", "hpc1"); + + assertOutput(format, out, new ManagedIdentityView("organization1", parseJson(IDENTITY, ManagedIdentityDbDtoAbstractGridConfig.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdate(OutputType format, MockServerClient mock) { + mockIdentities(mock); + // Keeps the current name and port, sends the platform so Platform can read the config + mock.when( + request().withMethod("PUT").withPath("/identities/31").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"managedIdentity\":{\"name\":\"hpc1\",\"platform\":\"slurm-platform\",\"config\":{\"discriminator\":\"slurm-platform\",\"hostName\":\"new.example.com\",\"port\":22}}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "update", "-o", "organization1", "-i", "31", "-H", "new.example.com"); + + assertOutput(format, out, new ManagedIdentityUpdated("organization1", 31L, "hpc1")); + } + + @Test + void testUpdateWithoutChanges(MockServerClient mock) { + ExecOut out = exec(mock, "managed-identities", "update", "-o", "organization1", "-i", "31"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Nothing to update"), out.stdErr); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteForce(OutputType format, MockServerClient mock) { + mockIdentities(mock); + mock.when( + request().withMethod("DELETE").withPath("/identities/32").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("checked", "false"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "delete", "-o", "organization1", "-n", "hpc2", "--force"); + + assertOutput(format, out, new ManagedIdentityDeleted("organization1", 32L, "hpc2")); + } + + @Test + void testDeleteInUse(MockServerClient mock) { + mockIdentities(mock); + mock.when( + request().withMethod("DELETE").withPath("/identities/31").withQueryStringParameter("checked", "true"), exactly(1) + ).respond( + response().withStatusCode(409).withBody("{\"managedCredentialsId\":\"41\",\"conflicts\":[{\"type\":\"workflow\",\"id\":\"abc\",\"name\":\"happy_turing\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "managed-identities", "delete", "-o", "organization1", "-i", "31"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("workflow 'happy_turing' (abc)"), out.stdErr); + assertTrue(out.stdErr.contains("Use --force"), out.stdErr); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsList(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mockCredentials(mock, null); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1"); + + assertOutput(format, out, new ManagedCredentialsList("organization1", "hpc1", List.of( + parseJson(OWN_ROW, ListManagedCredentialsRespDto.class), + parseJson(MISSING_ROW, ListManagedCredentialsRespDto.class) + ))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsAddForSelf(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mock.when( + request().withMethod("POST").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"provider\":\"ssh\",\"credentials\":{\"provider\":\"ssh\",\"keys\":{\"privateKey\":\"private_key\",\"passphrase\":\"secret\"}},\"metadata\":{\"userName\":\"jordi_hpc\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"managedCredentials\":{\"id\":41}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", + "add", "-l", "jordi_hpc", "-k", tempFile("private_key", "id_rsa", ""), "-p", "secret"); + + assertOutput(format, out, new ManagedCredentialsAdded("hpc1", 41L)); + mock.verify(request().withMethod("POST").withPath("/identities/31/credentials").withQueryStringParameter("userId", ".*"), org.mockserver.verify.VerificationTimes.exactly(0)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsAddForMember(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mockCredentials(mock, "alice"); + mock.when( + request().withMethod("POST").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("userId", "77") + .withBody(json("{\"metadata\":{\"userName\":\"alice_hpc\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"managedCredentials\":{\"id\":42}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-i", "31", + "add", "-m", "alice", "-l", "alice_hpc", "-k", tempFile("private_key", "id_rsa", "")); + + assertOutput(format, out, new ManagedCredentialsAdded("hpc1", 42L)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsUpdateForSelf(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mockCredentials(mock, "jordi"); + mock.when( + request().withMethod("PUT").withPath("/identities/31/credentials/41").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"provider\":\"ssh\",\"credentials\":{\"provider\":\"ssh\",\"keys\":{\"privateKey\":\"new_key\"}},\"metadata\":{\"userName\":\"jordi2\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", + "update", "-l", "jordi2", "-k", tempFile("new_key", "id_rsa", "")); + + assertOutput(format, out, new ManagedCredentialsUpdated("hpc1", 41L)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsDeleteForce(OutputType format, MockServerClient mock) { + mockIdentities(mock); + mockCredentials(mock, "jordi"); + mock.when( + request().withMethod("DELETE").withPath("/identities/31/credentials/41").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("checked", "false"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", "delete", "--force"); + + assertOutput(format, out, new ManagedCredentialsDeleted("hpc1", 41L)); + } + + @Test + void testCredentialsDeleteMissing(MockServerClient mock) { + mockIdentities(mock); + mockCredentials(mock, "alice"); + + ExecOut out = exec(mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", "delete", "-m", "alice"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Member 'alice' has no credentials for managed identity 'hpc1'"), out.stdErr); + } +} From bf24499c00673e112b48e415f5d2db7203c65262 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:25:53 +0200 Subject: [PATCH 23/28] fix(managed-identities): list credentials for every member The endpoint returns one row per organization member, 50 by default. The list silently stopped at 50 members. Page through all rows, 100 at a time, in the helper that the list and the member lookups share. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../managedidentities/CredentialsCmd.java | 14 +++++++++++++- .../ManagedIdentitiesCmdTest.java | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java index cd4442d0d..cbab94884 100644 --- a/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java @@ -34,6 +34,7 @@ import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; +import java.util.ArrayList; import java.util.List; @Command( @@ -87,8 +88,19 @@ public ListManagedCredentialsRespDto memberCredentials(Long orgId, ManagedIdenti .orElseThrow(() -> new TowerException(String.format("Member '%s' has no credentials for managed identity '%s'", userName, identity.getName()))); } + // Platform returns one row per organization member, 50 by default and at most 100 per page + private static final int PAGE_SIZE = 100; + private List listManagedCredentials(Long orgId, Long identityId, String search) throws ApiException { - return identitiesApi().listManagedCredentials(identityId, orgId, null, search, null, null).getManagedCredentials(); + List all = new ArrayList<>(); + List page; + do { + page = identitiesApi().listManagedCredentials(identityId, orgId, null, search, PAGE_SIZE, all.size()).getManagedCredentials(); + if (page != null) { + all.addAll(page); + } + } while (page != null && page.size() == PAGE_SIZE); + return all; } public static CreateManagedCredentialsRequest createRequest(SSHSecurityKeys keys, String linuxUserName) { diff --git a/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java b/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java index 05e4880fb..d3ed577d6 100644 --- a/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java @@ -40,6 +40,7 @@ import java.io.IOException; import java.util.List; +import java.util.Collections; import static io.seqera.tower.cli.utils.JsonHelper.parseJson; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -199,6 +200,23 @@ void testCredentialsList(OutputType format, MockServerClient mock) throws IOExce ))); } + @Test + void testCredentialsListFetchesEveryPage(MockServerClient mock) { + mockIdentities(mock); + String fullPage = "{\"managedCredentials\":[" + String.join(",", Collections.nCopies(100, MISSING_ROW)) + "],\"totalSize\":101}"; + mock.when( + request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("offset", "0").withQueryStringParameter("max", "100"), exactly(1) + ).respond(response().withStatusCode(200).withBody(fullPage).withContentType(MediaType.APPLICATION_JSON)); + mock.when( + request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("offset", "100").withQueryStringParameter("max", "100"), exactly(1) + ).respond(response().withStatusCode(200).withBody("{\"managedCredentials\":[" + OWN_ROW + "],\"totalSize\":101}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(OutputType.json, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1"); + + assertEquals(0, out.exitCode, out.stdErr); + assertEquals(101, out.stdOut.split("\"userName\"", -1).length - 1, out.stdOut); + } + @ParameterizedTest @EnumSource(OutputType.class) void testCredentialsAddForSelf(OutputType format, MockServerClient mock) throws IOException { From bf6af49024f355a0f87d09b25d32446cc3069272 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:26:21 +0200 Subject: [PATCH 24/28] test: capture the native binary's output verbatim execBinary read the binary's stdout line by line and re-printed each line with println. On Windows that turned every line feed inside printed content into CRLF. The new commands print server content verbatim (e.g. data-link download scripts), so their binary tests failed only on the Windows native job even though tw's output was right. Copy the raw streams instead, which matches how in-process tests capture output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/io/seqera/tower/cli/BaseCmdTest.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/test/java/io/seqera/tower/cli/BaseCmdTest.java b/src/test/java/io/seqera/tower/cli/BaseCmdTest.java index 791bd2a7d..b6bd58ecc 100644 --- a/src/test/java/io/seqera/tower/cli/BaseCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/BaseCmdTest.java @@ -32,10 +32,10 @@ import picocli.CommandLine; import picocli.CommandLine.ExitCode; -import java.io.BufferedReader; +import java.nio.charset.Charset; +import java.io.UncheckedIOException; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Files; @@ -137,8 +137,9 @@ private ExecOut execBinary(OutputType format, MockServerClient mock, String comm builder.command(ArrayUtils.insert(0, buildArgs(format, mock, args), command)); Process process = builder.start(); - StreamGobbler consumeOut = new StreamGobbler(process.getInputStream(), outWriter::println); - StreamGobbler consumeErr = new StreamGobbler(process.getErrorStream(), errWriter::println); + // Copy the raw streams: re-joining lines with println turned line feeds inside printed content into CRLF on Windows + StreamGobbler consumeOut = new StreamGobbler(process.getInputStream(), outWriter::print); + StreamGobbler consumeErr = new StreamGobbler(process.getErrorStream(), errWriter::print); Future taskOut = Executors.newSingleThreadExecutor().submit(consumeOut); Future taskErr = Executors.newSingleThreadExecutor().submit(consumeErr); int exitCode = process.waitFor(); @@ -246,7 +247,11 @@ public StreamGobbler(InputStream inputStream, Consumer consumer) { @Override public void run() { - new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(consumer); + try { + consumer.accept(new String(inputStream.readAllBytes(), Charset.defaultCharset())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } } From 6dd9b0ec1f328725a344259f9d67b5f65a130d52 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:27:13 +0200 Subject: [PATCH 25/28] docs: document the commands and options added for API 1.101.0 Sections for teams and organization quotas, access tokens and managed identities, plus a summary of the new options on existing commands. Examples are plain commands in bash blocks (markdownlint MD014). Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/USAGE.md b/USAGE.md index fd2b9673c..a5032e429 100644 --- a/USAGE.md +++ b/USAGE.md @@ -709,6 +709,49 @@ $ tw data-links browse -w seqeralabs/showcase -i v1-user-6d8f44c239e2a098b3e02e9 FOLDER | technical/ | 0 ``` +## Organization administration + +These commands act on an organization (`-o`/`--organization`). Most require the organization `OWNER` role. + +### Teams and quotas + +- `tw teams view|update|workspaces`: view a team, update its name or description, and list its workspaces. +- `tw organizations quotas`: view organization quotas. + +## Personal access + +### Access tokens + +Run `tw access-tokens -h` to view the supported operations: `list`, `add`, and `delete`. The token value is only shown when the token is created. + +```bash +tw access-tokens add -n ci +``` + +## Managed identities + +Managed identities give organization members individual SSH access to an HPC cluster (Slurm, LSF, UGE, Altair PBS, or Moab). Run `tw managed-identities -h` to view the supported operations. Each member then adds their own credentials: + +```bash +tw managed-identities add -o my-org -n hpc1 -p slurm -H login.hpc.example.com +tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.ssh/id_rsa +``` + +## Other additions + +- `tw compute-envs enable|disable`: enable or disable a compute environment. +- `tw compute-envs add`: + - AWS Batch: CloudWatch log group and DRAGEN options. + - AWS Cloud: `--log-group`. Google Cloud: `--project-id`. + - HPC platforms: `--propagate-head-job-options`. + - Seqera Compute: `--data-retention-policy`. +- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. +- `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. +- `tw pipelines schema`: show a pipeline's parameter schema. +- `tw studios extend|rename-checkpoint`. +- `tw data-links delete-content|download-script`. +- `tw datasets disable-version`. + [compute-envs]: https://docs.seqera.io/platform/latest/compute-envs/overview [credentials]: https://docs.seqera.io/platform/latest/credentials/overview [git-integration]: https://docs.seqera.io/platform/latest/git/overview From e4f24100d5519e8842ec135312f2d5a8c697ed27 Mon Sep 17 00:00:00 2001 From: Matteo Fiandesio Date: Mon, 5 Oct 2026 16:27:10 +0200 Subject: [PATCH 26/28] fix(runs): authenticate 'tw runs star' and 'tw runs unstar' Both commands failed with "Unauthorized" against a live Platform, even though the caller's token was valid and the same request made by hand succeeded. The workflow star endpoints are missing their security requirement in the Platform OpenAPI spec, so the generated SDK passes no auth names for them. ApiClient.invokeAPI skips authentication entirely when the auth name array is null, rather than falling back to the configured scheme, so the request went out with no Authorization header at all. Default the auth names to BearerAuth when an operation declares none. This routes through the SDK's own authentication, reusing the token already set with setBearerToken. In SDK 1.200.0 eleven operations declare no security: the three *WorkflowStar ones, six GA4GH ones, ServiceInfoApi.info and NextflowApi.nextflowVersions. The last two are genuinely public and the GA4GH ones target the same host, so sending the token is harmless; a token is always present by the time the client is built. The existing star tests matched on method and path only, so an unauthenticated request satisfied them. Assert the Authorization header instead. The mock server is shared across the test class without a reset, so the verification allows for the star requests other tests record. This is a workaround, not the cure: the spec is still missing the security block in SDK 1.230.0. Once that is fixed the override becomes a no-op. Co-Authored-By: Claude Opus 5 (1M context) --- .../tower/cli/commands/AbstractApiCmd.java | 14 ++++++++++ .../io/seqera/tower/cli/runs/RunsCmdTest.java | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+) 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 0eef44813..1fccc926c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.ApiClient; import io.seqera.tower.ApiException; +import io.seqera.tower.ApiResponse; +import io.seqera.tower.Pair; import io.seqera.tower.api.ActionsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.ComputeEnvsApi; @@ -78,6 +80,7 @@ import picocli.CommandLine; import javax.ws.rs.client.Entity; +import javax.ws.rs.core.GenericType; import javax.ws.rs.core.MediaType; import java.io.IOException; import java.net.URLConnection; @@ -318,6 +321,17 @@ public ClientConfig getDefaultClientConfig() { return config; } + @Override + public ApiResponse invokeAPI(String operation, String path, String method, List queryParams, Object body, Map headerParams, Map cookieParams, Map formParams, String accept, String contentType, String[] authNames, GenericType returnType, boolean isBodyNullable) throws ApiException { + // Some operations are missing their security requirement in the OpenAPI spec (e.g. the workflow + // star endpoints), so the generated SDK passes no auth names and skips authentication entirely, + // sending the request unauthenticated. Fall back to the only scheme this client uses. + if (authNames == null || authNames.length == 0) { + authNames = new String[]{"BearerAuth"}; + } + return super.invokeAPI(operation, path, method, queryParams, body, headerParams, cookieParams, formParams, accept, contentType, authNames, returnType, isBodyNullable); + } + @Override protected void applyDebugSetting(ClientConfig clientConfig) { if (app().verbose) { 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 41fdbb79c..efe938f42 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -198,6 +198,34 @@ void testUnstarNotStarred(MockServerClient mock) { assertEquals(1, out.exitCode); } + @Test + void testStarSendsAuthorization(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"workflowId\":\"5dAZoXrcmZXRO4\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + exec(mock, "runs", "star", "-i", "5dAZoXrcmZXRO4"); + + mock.verify(request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star") + .withHeader("Authorization", "Bearer " + token()), VerificationTimes.atLeast(1)); + } + + @Test + void testUnstarSendsAuthorization(MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + exec(mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + + mock.verify(request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star") + .withHeader("Authorization", "Bearer " + token()), VerificationTimes.atLeast(1)); + } + @Test void testCancelForbidden(MockServerClient mock) { mock.when( From 11f4ca13158b86cd42ea2f3cde1e33e83742f519 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 11:33:04 +0200 Subject: [PATCH 27/28] refactor: use the SDK 1.233 types for run logs and managed-identity conflicts These commands were written against SDK 1.200.0, where LogPage.entries was typed Object and managed credentials had no conflict response of their own. SDK 1.233.0 types the entries as List and adds DeleteManagedCredentialsConflictResponse, so drop both workarounds. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AbstractManagedIdentitiesCmd.java | 10 ++++------ .../seqera/tower/cli/commands/runs/log/LogCmd.java | 13 ++----------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java index 7e732837c..2db390514 100644 --- a/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java @@ -22,7 +22,7 @@ import io.seqera.tower.cli.commands.AbstractApiCmd; import io.seqera.tower.cli.exceptions.CredentialsInUseException; import io.seqera.tower.cli.exceptions.ManagedIdentityNotFoundException; -import io.seqera.tower.model.DeleteCredentialsConflictResponse; +import io.seqera.tower.model.DeleteManagedCredentialsConflictResponse; import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; import picocli.CommandLine.Command; @@ -56,7 +56,7 @@ protected static void deleteUnlessInUse(String ref, DeleteCall call) throws ApiE try { call.run(); } catch (ApiException e) { - DeleteCredentialsConflictResponse conflict = decodeConflict(e); + DeleteManagedCredentialsConflictResponse conflict = decodeConflict(e); if (conflict != null) { throw new CredentialsInUseException(ref, conflict.getConflicts()); } @@ -64,14 +64,12 @@ protected static void deleteUnlessInUse(String ref, DeleteCall call) throws ApiE } } - // The managed credentials 409 body has the same shape as the credentials one, whose conflict type - // CredentialsInUseException reports. - private static DeleteCredentialsConflictResponse decodeConflict(ApiException e) { + private static DeleteManagedCredentialsConflictResponse decodeConflict(ApiException e) { if (e.getCode() != 409 || e.getResponseBody() == null) { return null; } try { - DeleteCredentialsConflictResponse conflict = parseJson(e.getResponseBody(), DeleteCredentialsConflictResponse.class); + DeleteManagedCredentialsConflictResponse conflict = parseJson(e.getResponseBody(), DeleteManagedCredentialsConflictResponse.class); return conflict.getConflicts() == null || conflict.getConflicts().isEmpty() ? null : conflict; } catch (JsonProcessingException ignored) { // Not a conflict body: let the original error through diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java index 24197e007..2dddfe29a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java @@ -25,8 +25,6 @@ import io.seqera.tower.model.LogPage; import picocli.CommandLine; -import java.util.List; - @CommandLine.Command( name = "log", description = "Display the execution log of a pipeline run or task" @@ -54,15 +52,8 @@ protected Response exec() throws ApiException { // Live streams (e.g. CloudWatch) return one page per call without flagging truncation; an empty page means // the end of what is available, even though the stream keeps returning a cursor - List entries = entries(page); - boolean hasEntries = entries != null && !entries.isEmpty(); + boolean hasEntries = page.getEntries() != null && !page.getEntries().isEmpty(); String nextPage = hasEntries ? page.getForwardToken() : null; - return new RunLog(entries, Boolean.TRUE.equals(page.getTruncated()), page.getMessage(), nextPage); - } - - // The API spec types LogPage.entries as an Iterator object, so the SDK exposes it as Object, but Platform sends a JSON - // array of strings, which Jackson deserializes as a List - private static List entries(LogPage page) { - return page.getEntries() instanceof List list ? list.stream().map(String::valueOf).toList() : null; + return new RunLog(page.getEntries(), Boolean.TRUE.equals(page.getTruncated()), page.getMessage(), nextPage); } } From 4148f96cd108edc62c57f49ec5a87a7ec0adb66a Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 11:33:06 +0200 Subject: [PATCH 28/28] build(native): register reflection metadata for the new commands Entries for the new commands, option groups and responses, plus classes master already lacked (e.g. computeenvs.ValidateCmd, RunView), 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 | 565 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 565 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index c144d6570..d45dff074 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -154,6 +154,12 @@ "allDeclaredFields":true, "allDeclaredMethods":true }, +{ + "name":"io.seqera.tower.cli.commands.AccessTokensCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.ActionsCmd", "allDeclaredFields":true, @@ -208,6 +214,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.ManagedIdentitiesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.MembersCmd", "allDeclaredFields":true, @@ -268,6 +280,33 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.DeleteCmd$TokenRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.actions.AbstractActionsCmd", "allDeclaredFields":true, @@ -378,6 +417,20 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.DisableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.EnableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.ExportCmd", "allDeclaredFields":true, @@ -408,6 +461,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.ValidateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"force"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.ViewCmd", "allDeclaredFields":true, @@ -672,6 +732,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.platforms.AzCloudPlatform$SchedOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"machineTypes"}, {"name":"maxCpusPerUser"}, {"name":"maxSpotAttempts"}, {"name":"nvmeEnabled"}, {"name":"predictionModel"}, {"name":"provisioningModel"}, {"name":"schedEnabled"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.platforms.EksPlatform", "allDeclaredFields":true, @@ -708,6 +775,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.platforms.GoogleCloudPlatform$SchedOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"billingExportTable"}, {"name":"machineTypes"}, {"name":"maxCpusPerUser"}, {"name":"maxSpotAttempts"}, {"name":"nvmeEnabled"}, {"name":"predictionModel"}, {"name":"provisioningModel"}, {"name":"schedEnabled"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.platforms.K8sPlatform", "allDeclaredFields":true, @@ -832,6 +906,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.credentials.ValidateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRefOptions"}, {"name":"force"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.credentials.add.AbstractAddCmd", "allDeclaredFields":true, @@ -1133,12 +1214,26 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.data.links.DeleteContentCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRef"}, {"name":"dataLinkRefOptions"}, {"name":"dirs"}, {"name":"files"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.data.links.DownloadCmd", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.data.links.DownloadScriptCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRef"}, {"name":"dataLinkRefOptions"}, {"name":"dirs"}, {"name":"files"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.data.links.ListCmd", "allDeclaredFields":true, @@ -1227,6 +1322,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.datasets.DisableVersionCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"datasetRefOptions"}, {"name":"version"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.datasets.DownloadCmd", "allDeclaredFields":true, @@ -1382,6 +1484,101 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.AbstractManagedIdentitiesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"hostName"}, {"name":"name"}, {"name":"organizationRef"}, {"name":"platform"}, {"name":"port"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.CredentialsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"force"}, {"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ManagedIdentityRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"managedIdentity"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ManagedIdentityRefOptions$ManagedIdentityRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.SshManagedCredentialsMetadata", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"userName"}] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"hostName"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"port"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"member"}, {"name":"parent"}, {"name":"ssh"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"force"}, {"name":"member"}, {"name":"parent"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.SshCredentialsOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"linuxUserName"}, {"name":"passphrase"}, {"name":"privateKey"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"member"}, {"name":"parent"}, {"name":"ssh"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.members.AbstractMembersClass", "allDeclaredFields":true, @@ -1458,6 +1655,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.organizations.QuotasCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRefOptions"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.organizations.UpdateCmd", "allDeclaredFields":true, @@ -1546,6 +1750,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.NullableLaunchOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"fusionVersion"}, {"name":"nextflowVersion"}, {"name":"outputDir"}, {"name":"syntaxParser"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.pipelines.PipelineRefOptions", "allDeclaredFields":true, @@ -1558,6 +1769,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.SchemaCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"params"}, {"name":"pipelineRefOptions"}, {"name":"versionRef"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.pipelines.UpdateCmd", "allDeclaredFields":true, @@ -1682,6 +1900,20 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.runs.StarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.runs.UnstarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.ViewCmd", "allDeclaredFields":true, @@ -1694,6 +1926,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.runs.log.LogCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"parentCommand"}, {"name":"task"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.metrics.MetricsCmd", "allDeclaredFields":true, @@ -1805,6 +2044,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.ExtendCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.ListCmd", "allDeclaredFields":true, @@ -1823,6 +2069,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.RenameCheckpointCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"checkpointId"}, {"name":"newName"}, {"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StartCmd", "allDeclaredFields":true, @@ -1865,6 +2118,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.StudioRemoteConfigOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"repository"}, {"name":"revision"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StudioTemplateOptions", "allDeclaredFields":true, @@ -1924,6 +2184,41 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.teams.TeamRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"team"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.TeamRefOptions$TeamRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"idpGroup"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.WorkspacesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"paginationOptions"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.teams.members.AddCmd", "allDeclaredFields":true, @@ -2048,6 +2343,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.CredentialsValidated", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.InfoResponse", "allDeclaredFields":true, @@ -2059,6 +2362,24 @@ "allDeclaredFields":true, "allDeclaredMethods":true }, +{ + "name":"io.seqera.tower.cli.responses.accesstokens.AccessTokenAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.accesstokens.AccessTokenDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.accesstokens.AccessTokensList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.actions.ActionAdd", "allDeclaredFields":true, @@ -2107,6 +2428,18 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvDisabled", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvEnabled", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvExport", "allDeclaredFields":true, @@ -2125,6 +2458,44 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvValidated", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvsPrimaryGet", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvsPrimarySet", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.data.DataLinkContentDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.data.DataLinkContentList", "allDeclaredFields":true, @@ -2137,6 +2508,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.data.DataLinkDownloadScript", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.data.DataLinkFileTransferResult", "allDeclaredFields":true, @@ -2178,6 +2555,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.datasets.DatasetDownload", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.datasets.DatasetList", "allDeclaredFields":true, @@ -2196,6 +2581,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.datasets.DatasetVersionDisabled", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.datasets.DatasetVersionsList", "allDeclaredFields":true, @@ -2244,6 +2635,60 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentitiesList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.members.MembersAdded", "allDeclaredFields":true, @@ -2274,6 +2719,22 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationQuotasView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationsAdded", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.organizations.OrganizationsDeleted", "allDeclaredFields":true, @@ -2292,6 +2753,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationsView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.participants.ParticipantAdded", "allDeclaredFields":true, @@ -2328,6 +2797,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.PipelinesDeleted", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelines.PipelinesExport", "allDeclaredFields":true, @@ -2340,6 +2817,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.PipelinesSchema", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelines.PipelinesUpdated", "allDeclaredFields":true, @@ -2400,18 +2885,68 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.RunLog", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunStarred", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.RunSubmited", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.RunView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunViewMetrics", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunsDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.tasks.TaskView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.tasks.TasksView", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.utils.MetricFormatMapper", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.secrets.SecretAdded", "allDeclaredFields":true, @@ -2442,6 +2977,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioCheckpointsList", "allDeclaredFields":true, @@ -2454,6 +2995,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioLifespanExtended", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStartSubmitted", "allDeclaredFields":true, @@ -2508,6 +3055,24 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.teams.TeamUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.teams.TeamView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.teams.TeamWorkspacesList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.teams.TeamsList", "allDeclaredFields":true,