From 8bb7149e74595920eb0af8df62e0c914b822e51d Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:38:14 +0200 Subject: [PATCH 1/7] feat(compute-envs): add Intelligent Compute options to cloud platforms Platform API 1.167.0 added SchedConfig fields that tw never set, so Intelligent Compute CEs created from the CLI could not choose a prediction model, NVMe/local-SSD instances, or the AWS backend strategy and warm pool. Options are added only where Platform accepts them, matching the tower-web forms and WithSeqeraSchedulerSupport.validateSchedConfig: - all cloud platforms: --prediction-model, --nvme-storage; - aws-cloud only: --backend-strategy and the warm pool options (--warm-pool, --warm-pool-size, --warm-pool-scale-to-zero). diskAllocation is not exposed: nvmeEnabled is Platform's boolean view over it and its only value is 'nvme'. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platforms/AwsCloudPlatform.java | 46 +++++++-- .../platforms/AzCloudPlatform.java | 18 ++-- .../platforms/GoogleCloudPlatform.java | 18 ++-- .../platforms/AwsCloudPlatformTest.java | 98 +++++++++++++++++++ .../platforms/AzCloudPlatformTest.java | 72 ++++++++++++++ .../platforms/GoogleCloudPlatformTest.java | 72 ++++++++++++++ 6 files changed, 303 insertions(+), 21 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 a67a8ae3d..1e937a3e3 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 @@ -21,6 +21,7 @@ import io.seqera.tower.model.AwsCloudConfig; import io.seqera.tower.model.ComputeEnvComputeConfig.PlatformEnum; import io.seqera.tower.model.SchedConfig; +import io.seqera.tower.model.SchedConfigPool; import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Option; @@ -70,13 +71,13 @@ public AwsCloudConfig computeConfig() throws ApiException, IOException { .allowBuckets(allowBuckets); if (sched != null) { - SchedConfig schedConfig = new SchedConfig(); - if (sched.provisioningModel != null) { - schedConfig.provisioningModel(sched.provisioningModel); - } - if (sched.machineTypes != null) { - schedConfig.machineTypes(sched.machineTypes); - } + SchedConfig schedConfig = new SchedConfig() + .provisioningModel(sched.provisioningModel) + .machineTypes(sched.machineTypes) + .predictionModel(sched.predictionModel) + .nvmeEnabled(sched.nvmeEnabled) + .backendStrategy(sched.backendStrategy) + .pool(sched.warmPool()); config.schedConfig(schedConfig); } @@ -126,6 +127,37 @@ public static class SchedOptions { @Option(names = {"--sched-machine-types"}, description = "EC2 instance types for compute nodes managed by the Seqera scheduler. Comma-separated list (e.g., m5.xlarge,c5.2xlarge). Leave empty to let the scheduler select the most cost-effective types.", split = ",") public List machineTypes; + + @Option(names = {"--prediction-model"}, description = "Model the Seqera scheduler uses to predict task resource requirements. Suggested values: none, qr/v1, qr/v2, qr/v3. If absent, the scheduler default (none) applies.") + public String predictionModel; + + @Option(names = {"--nvme-storage"}, description = "Restrict the Seqera scheduler to EC2 instance types that provide local SSD (NVMe) storage for faster I/O.") + public Boolean nvmeEnabled; + + @Option(names = {"--backend-strategy"}, description = "Backend the Seqera scheduler uses to run tasks. ECS delegates task execution to AWS ECS; EC2 runs tasks directly on EC2 instances. Valid values: ECS, EC2.") + public SchedConfig.BackendStrategyEnum backendStrategy; + + @Option(names = {"--warm-pool"}, description = "Keep a pool of idle VMs ready to absorb incoming tasks with minimal start latency. Requires --warm-pool-size. Only applies with --backend-strategy EC2.") + public Boolean warmPoolEnabled; + + @Option(names = {"--warm-pool-size"}, description = "Number of idle VMs to keep in the warm pool. Must be greater than zero.") + public Integer warmPoolSize; + + @Option(names = {"--warm-pool-scale-to-zero"}, paramLabel = "", description = "Seconds of inactivity after which the warm pool scales to zero. Set to 0 to never scale to zero.") + public Integer warmPoolScaleToZeroSecs; + + SchedConfigPool warmPool() { + if (warmPoolEnabled == null && warmPoolSize == null && warmPoolScaleToZeroSecs == null) { + return null; + } + if (Boolean.TRUE.equals(warmPoolEnabled) && (warmPoolSize == null || warmPoolSize < 1)) { + throw new TowerRuntimeException("Option --warm-pool requires --warm-pool-size greater than zero."); + } + return new SchedConfigPool() + .enabled(warmPoolEnabled) + .desiredWarm(warmPoolSize) + .scaleToZeroSecs(warmPoolScaleToZeroSecs); + } } public static class AdvancedOptions { diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java index 75bdd801a..dd4f600aa 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java @@ -69,13 +69,11 @@ public AzCloudConfig computeConfig() throws ApiException, IOException { .resourceGroup(resourceGroup); if (sched != null) { - SchedConfig schedConfig = new SchedConfig(); - if (sched.provisioningModel != null) { - schedConfig.provisioningModel(sched.provisioningModel); - } - if (sched.machineTypes != null) { - schedConfig.machineTypes(sched.machineTypes); - } + SchedConfig schedConfig = new SchedConfig() + .provisioningModel(sched.provisioningModel) + .machineTypes(sched.machineTypes) + .predictionModel(sched.predictionModel) + .nvmeEnabled(sched.nvmeEnabled); config.schedConfig(schedConfig); } @@ -113,6 +111,12 @@ public static class SchedOptions { @Option(names = {"--sched-machine-types"}, description = "Azure VM sizes for compute nodes managed by the Seqera scheduler. Comma-separated list (e.g., Standard_D4s_v3,Standard_E4s_v3). Leave empty to let the scheduler select the most cost-effective sizes.", split = ",") public List machineTypes; + + @Option(names = {"--prediction-model"}, description = "Model the Seqera scheduler uses to predict task resource requirements. Suggested values: none, qr/v1, qr/v2, qr/v3. If absent, the scheduler default (none) applies.") + public String predictionModel; + + @Option(names = {"--nvme-storage"}, description = "Restrict the Seqera scheduler to Azure VM sizes that provide local SSD storage for faster I/O.") + public Boolean nvmeEnabled; } public static class AdvancedOptions { diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java index eeea3031f..fa8321553 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 @@ -75,13 +75,11 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { .zone(zone); if (sched != null) { - SchedConfig schedConfig = new SchedConfig(); - if (sched.provisioningModel != null) { - schedConfig.provisioningModel(sched.provisioningModel); - } - if (sched.machineTypes != null) { - schedConfig.machineTypes(sched.machineTypes); - } + SchedConfig schedConfig = new SchedConfig() + .provisioningModel(sched.provisioningModel) + .machineTypes(sched.machineTypes) + .predictionModel(sched.predictionModel) + .nvmeEnabled(sched.nvmeEnabled); config.schedConfig(schedConfig); } @@ -148,6 +146,12 @@ public static class SchedOptions { @Option(names = {"--sched-machine-types"}, description = "Compute Engine machine types for compute nodes managed by the Seqera scheduler. Comma-separated list (e.g., n2-standard-4,c2-standard-8). Leave empty to let the scheduler select the most cost-effective types.", split = ",") public List machineTypes; + + @Option(names = {"--prediction-model"}, description = "Model the Seqera scheduler uses to predict task resource requirements. Suggested values: none, qr/v1, qr/v2, qr/v3. If absent, the scheduler default (none) applies.") + public String predictionModel; + + @Option(names = {"--nvme-storage"}, description = "Restrict the Seqera scheduler to Compute Engine machine types that provide local SSD (NVMe) storage for faster I/O.") + public Boolean nvmeEnabled; } public static class AdvancedOptions { 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 b35931e29..d352c6403 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 @@ -832,4 +832,102 @@ void testAddWithFusionMetricsDisabled(MockServerClient mock) throws IOException assertEquals(0, out.exitCode); assertEquals(expected.toString(), out.stdOut); } + + @Test + void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOException { + mock.reset(); + + // given + mock.when( + request() + .withMethod("GET") + .withPath("/credentials") + .withQueryStringParameter("platformId", "aws-cloud"), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"aws\",\"description\":null,\"discriminator\":\"aws\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") + ); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-aws-cloud-ic", + "platform": "aws-cloud", + "config": { + "workDir": "s3://my-bucket", + "region": "us-east-1", + "fusion2Enabled": true, + "waveEnabled": true, + "schedEnabled": true, + "schedConfig": { + "predictionModel": "qr/v3", + "nvmeEnabled": true, + "backendStrategy": "EC2", + "pool": { + "enabled": true, + "desiredWarm": 2, + "scaleToZeroSecs": 600 + } + } + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + // when + ExecOut out = exec(mock, "compute-envs", "add", "aws-cloud", + "-n", "my-aws-cloud-ic", + "--work-dir", + "s3://my-bucket", + "-r", + "us-east-1", + "--sched-enabled", + "--prediction-model", + "qr/v3", + "--nvme-storage", + "--backend-strategy", + "EC2", + "--warm-pool", + "--warm-pool-size", + "2", + "--warm-pool-scale-to-zero", + "600" + ); + + // then + var expected = new ComputeEnvAdded("aws-cloud", "isnEDBLvHDAIteOEF44ow", "my-aws-cloud-ic", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } + + @Test + void testAddWithWarmPoolWithoutSizeFails(MockServerClient mock) { + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "aws-cloud", + "-n", "my-aws-cloud-pool", + "--work-dir", "s3://my-bucket", + "-r", "us-east-1", + "--sched-enabled", + "--warm-pool" + ); + + assertTrue(out.stdErr.contains("Option --warm-pool requires --warm-pool-size greater than zero."), "Expected warm pool size error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java index 91de216b3..533161451 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java @@ -242,4 +242,76 @@ void testAddWithScheduler(MockServerClient mock) throws IOException { assertEquals(0, out.exitCode); assertEquals(expected.toString(), out.stdOut); } + + @Test + void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOException { + mock.reset(); + + // given + mock.when( + request() + .withMethod("GET") + .withPath("/credentials") + .withQueryStringParameter("platformId", "azure-cloud"), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"azure\",\"description\":null,\"discriminator\":\"azure\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") + ); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-azure-cloud-ic", + "platform": "azure-cloud", + "config": { + "workDir": "az://my-container", + "region": "eastus", + "resourceGroup": "my-resource-group", + "fusion2Enabled": true, + "waveEnabled": true, + "schedEnabled": true, + "schedConfig": { + "predictionModel": "qr/v3", + "nvmeEnabled": true + } + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + // when + ExecOut out = exec(mock, "compute-envs", "add", "azure-cloud", + "-n", "my-azure-cloud-ic", + "--work-dir", + "az://my-container", + "-r", + "eastus", + "--resource-group", + "my-resource-group", + "--sched-enabled", + "--prediction-model", + "qr/v3", + "--nvme-storage" + ); + + // then + var expected = new ComputeEnvAdded("azure-cloud", "isnEDBLvHDAIteOEF44ow", "my-azure-cloud-ic", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java index 05b347fc9..904d75422 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java @@ -381,4 +381,76 @@ private static void mockCredentials(MockServerClient mock) { .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"description\":null,\"discriminator\":\"google\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") ); } + + @Test + void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOException { + mock.reset(); + + // given + mock.when( + request() + .withMethod("GET") + .withPath("/credentials") + .withQueryStringParameter("platformId", "google-cloud"), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"credentials\":[{\"id\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"description\":null,\"discriminator\":\"google\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-08T18:20:46Z\",\"dateCreated\":\"2021-09-08T12:57:04Z\",\"lastUpdated\":\"2021-09-08T12:57:04Z\"}]}") + ); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-google-cloud-ic", + "platform": "google-cloud", + "config": { + "workDir": "gs://my-bucket", + "region": "us-central1", + "zone": "us-central1-a", + "fusion2Enabled": true, + "waveEnabled": true, + "schedEnabled": true, + "schedConfig": { + "predictionModel": "qr/v3", + "nvmeEnabled": true + } + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + // when + ExecOut out = exec(mock, "compute-envs", "add", "google-cloud", + "-n", "my-google-cloud-ic", + "--work-dir", + "gs://my-bucket", + "-r", + "us-central1", + "-z", + "us-central1-a", + "--sched-enabled", + "--prediction-model", + "qr/v3", + "--nvme-storage" + ); + + // then + var expected = new ComputeEnvAdded("google-cloud", "isnEDBLvHDAIteOEF44ow", "my-google-cloud-ic", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } } From c267715ac2c9931443d4e9ec4fc1ddee0c298387 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:38:58 +0200 Subject: [PATCH 2/7] feat(organizations): add IdP group and SCIM commands Platform API 1.167 added the organization IdP group catalog and SCIM provisioning admin endpoints, which tw did not expose. Without them a team could not be linked to an IdP group from the CLI, and SCIM tokens could only be managed in the UI. Add 'tw organizations idp-groups' (list, add, delete) and 'tw organizations scim' (view, create-token, rotate-token, revoke-token). Both are gated by the IdP claims mapping feature (TOWER_IDP_CLAIMS_MAPPING_ENABLED plus an optional org allowlist), which is on by default in Enterprise and enabled in Seqera Cloud, so the help text names the requirement instead of calling it enterprise-only. 'idp-groups delete' resolves the group by display name, so the findIdpGroupByName helper lands here; 'teams --idp-group' reuses it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/AbstractApiCmd.java | 9 + .../tower/cli/commands/OrganizationsCmd.java | 4 + .../commands/organizations/IdpGroupsCmd.java | 35 ++++ .../cli/commands/organizations/ScimCmd.java | 37 ++++ .../organizations/idpgroups/AddCmd.java | 44 +++++ .../organizations/idpgroups/DeleteCmd.java | 56 ++++++ .../organizations/idpgroups/ListCmd.java | 40 +++++ .../organizations/scim/CreateTokenCmd.java | 40 +++++ .../organizations/scim/RevokeTokenCmd.java | 41 +++++ .../organizations/scim/RotateTokenCmd.java | 40 +++++ .../commands/organizations/scim/ViewCmd.java | 40 +++++ .../organizations/IdpGroupAdded.java | 36 ++++ .../organizations/IdpGroupDeleted.java | 35 ++++ .../organizations/IdpGroupsList.java | 51 ++++++ .../organizations/ScimConfigView.java | 57 +++++++ .../organizations/ScimTokenCreated.java | 48 ++++++ .../organizations/ScimTokenRevoked.java | 33 ++++ .../OrganizationIdentityCmdTest.java | 161 ++++++++++++++++++ 18 files changed, 807 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/IdpGroupsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/ScimCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/idpgroups/AddCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/idpgroups/DeleteCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/idpgroups/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/scim/CreateTokenCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/scim/RevokeTokenCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/scim/RotateTokenCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/organizations/scim/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/IdpGroupAdded.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/IdpGroupDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/IdpGroupsList.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/ScimConfigView.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/ScimTokenCreated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/organizations/ScimTokenRevoked.java create mode 100644 src/test/java/io/seqera/tower/cli/organizations/OrganizationIdentityCmdTest.java diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 628f90f05..e7b1cc76f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -63,6 +63,7 @@ import io.seqera.tower.model.ComputeEnvResponseDto; import io.seqera.tower.model.Credentials; import io.seqera.tower.model.DataStudioQueryAttribute; +import io.seqera.tower.model.IdpGroupEntry; import io.seqera.tower.model.ListComputeEnvsResponseEntry; import io.seqera.tower.model.ListPipelineVersionsResponse; import io.seqera.tower.model.ListWorkspacesAndOrgResponse; @@ -548,6 +549,14 @@ protected OrgAndWorkspaceDto findOrganizationByRef(String organizationRef) throw return orgAndWorkspaceDbDtoList.stream().findFirst().orElseThrow(() -> new OrganizationNotFoundException(organizationRef)); } + protected IdpGroupEntry findIdpGroupByName(Long orgId, String displayName) throws ApiException { + return orgsApi().listOrganizationIdpGroups(orgId).getGroups() + .stream() + .filter(group -> Objects.equals(group.getDisplayName(), displayName)) + .findFirst() + .orElseThrow(() -> new TowerException(String.format("IdP group '%s' not found in organization '%d'", displayName, orgId))); + } + protected List findOrCreateLabels(Long wspId, List