From 9590f78be1e26f3166aba26813fbd1753b06751b Mon Sep 17 00:00:00 2001 From: ramonamela <25862624+ramonamela@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:24:59 +0200 Subject: [PATCH 1/2] feat: add new Google Batch CE and credential features [COMP-1463] - WIF (Workload Identity Federation) support for Google credentials with --mode=workload-identity, --service-account-email, --workload-identity-provider, and --token-audience options - Network tags (--network-tags) with VPC requirement and GCP format validation, plus --network and --subnetwork options - Machine type selection: --head-job-machine-type (single) and --compute-jobs-machine-type (comma-separated list with wildcard support), mutually exclusive with instance templates - Boot disk image (--boot-disk-image) with format validation for projects/*/global/images/*, family paths, and batch-* short names - Fusion Snapshots (--fusion-snapshots) toggle requiring Fusion v2 All features include CLI validation matching backend/frontend rules. Code will compile once tower-java-sdk is bumped to include the new fields on GoogleSecurityKeys and GoogleBatchConfig. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../platforms/GoogleBatchPlatform.java | 107 +++++- .../credentials/providers/GoogleProvider.java | 77 +++- .../platforms/GoogleBatchPlatformTest.java | 358 ++++++++++++++++-- .../providers/GoogleProviderTest.java | 149 ++++++++ 4 files changed, 662 insertions(+), 29 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleBatchPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleBatchPlatform.java index 8e786f65..f2be52ca 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleBatchPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleBatchPlatform.java @@ -23,9 +23,17 @@ import picocli.CommandLine.Option; import java.io.IOException; +import java.util.List; +import java.util.regex.Pattern; public class GoogleBatchPlatform extends AbstractPlatform { + private static final Pattern NETWORK_TAG_PATTERN = Pattern.compile("^[a-z][-a-z0-9]*[a-z0-9]$"); + private static final Pattern MACHINE_TYPE_PATTERN = Pattern.compile("^[a-z][a-z0-9]*(-[a-z0-9*]+)*$"); + private static final Pattern BOOT_DISK_IMAGE_PATTERN = Pattern.compile("^(projects/[a-z0-9\\-_]+/global/images/(family/)?[a-z0-9\\-_]+|batch-[a-z0-9\\-]+)$"); + private static final int MAX_NETWORK_TAGS = 64; + private static final int MAX_TAG_LENGTH = 63; + @Option(names = {"--work-dir"}, description = "Nextflow work directory. Path where workflow intermediate files are stored. Must be a Google Cloud Storage bucket path (e.g., gs://your-bucket/work).", required = true) public String workDir; @@ -38,6 +46,9 @@ public class GoogleBatchPlatform extends AbstractPlatform { @Option(names = {"--fusion-v2"}, description = "Enable Fusion file system. Provides native access to Google Cloud Storage with low-latency I/O. Requires Wave containers.") public boolean fusionV2; + @Option(names = {"--fusion-snapshots"}, description = "Enable Fusion Snapshots (beta). Allows Fusion to restore jobs interrupted by Spot VM reclamation. Requires Fusion v2.") + public boolean fusionSnapshots; + @Option(names = {"--wave"}, description = "Enable Wave containers. Allows access to private container repositories and on-demand container provisioning.") public boolean wave; @@ -52,8 +63,13 @@ public GoogleBatchPlatform() { public GoogleBatchConfig computeConfig() throws ApiException, IOException { GoogleBatchConfig config = new GoogleBatchConfig(); + if (fusionSnapshots && !fusionV2) { + throw new IllegalArgumentException("Fusion Snapshots requires Fusion v2 to be enabled (--fusion-v2)."); + } + config .fusion2Enabled(fusionV2) + .fusionSnapshots(fusionSnapshots) .waveEnabled(wave) // Main @@ -62,11 +78,23 @@ public GoogleBatchConfig computeConfig() throws ApiException, IOException { // Advanced if (adv != null) { + if (adv.networkTags != null && !adv.networkTags.isEmpty()) { + validateNetworkTags(adv.networkTags, adv.network); + } + validateMachineTypes(adv); + validateBootDiskImage(adv.bootDiskImage); + config + .network(adv.network) + .subnetwork(adv.subnetwork) + .networkTags(adv.networkTags) .usePrivateAddress(adv.usePrivateAddress) .bootDiskSizeGb(adv.bootDiskSizeGb) + .bootDiskImage(adv.bootDiskImage) .headJobCpus(adv.headJobCpus) .headJobMemoryMb(adv.headJobMemoryMb) + .machineType(adv.headJobMachineType) + .computeJobsMachineType(adv.computeJobsMachineType) .serviceAccount(adv.serviceAccountEmail) .headJobInstanceTemplate(adv.headJobInstanceTemplate) .computeJobsInstanceTemplate(adv.computeJobInstanceTemplate); @@ -82,13 +110,82 @@ public GoogleBatchConfig computeConfig() throws ApiException, IOException { return config; } + private static void validateMachineTypeFormat(String machineType) { + if (!MACHINE_TYPE_PATTERN.matcher(machineType).matches()) { + throw new IllegalArgumentException(String.format("Invalid machine type '%s': must contain only lowercase letters, numbers, and hyphens.", machineType)); + } + } + + private static void validateMachineTypes(AdvancedOptions adv) { + if (adv.headJobMachineType != null && adv.headJobInstanceTemplate != null) { + throw new IllegalArgumentException("Head job machine type and head job instance template are mutually exclusive -- specify only one."); + } + if (adv.computeJobsMachineType != null && !adv.computeJobsMachineType.isEmpty() && adv.computeJobInstanceTemplate != null) { + throw new IllegalArgumentException("Compute jobs machine type and compute jobs instance template are mutually exclusive -- specify only one."); + } + if (adv.headJobMachineType != null) { + if (adv.headJobMachineType.contains("*")) { + throw new IllegalArgumentException("Wildcard machine type families are not supported for the head job -- select a specific machine type instead."); + } + validateMachineTypeFormat(adv.headJobMachineType); + } + if (adv.computeJobsMachineType != null) { + for (String mt : adv.computeJobsMachineType) { + validateMachineTypeFormat(mt); + } + } + } + + private static void validateBootDiskImage(String bootDiskImage) { + if (bootDiskImage != null && !BOOT_DISK_IMAGE_PATTERN.matcher(bootDiskImage).matches()) { + throw new IllegalArgumentException("Invalid boot disk image format. Use projects/{PROJECT}/global/images/{IMAGE}, projects/{PROJECT}/global/images/family/{FAMILY}, or a Batch image name (e.g., batch-debian)."); + } + } + + private static void validateNetworkTags(List tags, String network) { + if (network == null || network.isEmpty()) { + throw new IllegalArgumentException("Network tags require VPC configuration: set the '--network' option to use network tags."); + } + + if (tags.size() > MAX_NETWORK_TAGS) { + throw new IllegalArgumentException(String.format("Too many network tags: maximum is %d, provided %d.", MAX_NETWORK_TAGS, tags.size())); + } + + for (String tag : tags) { + if (tag == null || tag.isEmpty() || tag.length() > MAX_TAG_LENGTH) { + throw new IllegalArgumentException(String.format("Invalid network tag '%s': must be 1-63 characters.", tag)); + } + if (tag.length() == 1) { + if (!tag.matches("^[a-z]$")) { + throw new IllegalArgumentException(String.format("Invalid network tag '%s': single-character tags must be a lowercase letter.", tag)); + } + } else { + if (!NETWORK_TAG_PATTERN.matcher(tag).matches()) { + throw new IllegalArgumentException(String.format("Invalid network tag '%s': must start with a lowercase letter, end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", tag)); + } + } + } + } + public static class AdvancedOptions { + @Option(names = {"--network"}, description = "Google Cloud VPC network name or URI. Required when using network tags or subnets.") + public String network; + + @Option(names = {"--subnetwork"}, description = "Google Cloud VPC subnetwork name or URI. Must be in the same region as the compute environment location.") + public String subnetwork; + + @Option(names = {"--network-tags"}, split = ",", paramLabel = "", description = "Comma-separated list of network tags applied to VMs for firewall rule targeting. Tags must be lowercase, use only letters, numbers, and hyphens (1-63 chars). Requires --network.") + public List networkTags; + @Option(names = {"--use-private-address"}, description = "Do not attach a public IP address to VM instances. When enabled, only Google internal services are accessible. Requires Cloud NAT for external access.") public Boolean usePrivateAddress; @Option(names = {"--boot-disk-size"}, description = "Boot disk size in GB. Controls the root volume size for compute instances. If absent, Platform defaults to 50 GB.") public Integer bootDiskSizeGb; + @Option(names = {"--boot-disk-image"}, description = "Custom boot disk image for compute job VMs. Accepts: projects/{PROJECT}/global/images/{IMAGE}, projects/{PROJECT}/global/images/family/{FAMILY}, or a Batch image name (e.g., batch-debian).") + public String bootDiskImage; + @Option(names = {"--head-job-cpus"}, description = "Number of CPUs allocated to the Nextflow head job. Controls the compute resources for the main workflow orchestration process.") public Integer headJobCpus; @@ -98,10 +195,16 @@ public static class AdvancedOptions { @Option(names = {"--service-account-email"}, description = "Google Cloud service account email for pipeline execution. Grants fine-grained IAM permissions to Nextflow jobs.") public String serviceAccountEmail; - @Option(names = {"--head-job-template"}, description = "Google Compute Engine instance template for the Nextflow head job. Specify either the template name (if in the same project) or the fully qualified reference (projects/PROJECT_ID/global/instanceTemplates/TEMPLATE_NAME).") + @Option(names = {"--head-job-machine-type"}, description = "GCP machine type for the Nextflow head job (e.g., n2-standard-4). Mutually exclusive with --head-job-template.") + public String headJobMachineType; + + @Option(names = {"--head-job-template"}, description = "Google Compute Engine instance template for the Nextflow head job. Specify either the template name (if in the same project) or the fully qualified reference (projects/PROJECT_ID/global/instanceTemplates/TEMPLATE_NAME). Mutually exclusive with --head-job-machine-type.") public String headJobInstanceTemplate; - @Option(names = {"--compute-job-template"}, description = "Google Compute Engine instance template for pipeline compute jobs. Specify either the template name (if in the same project) or the fully qualified reference (projects/PROJECT_ID/global/instanceTemplates/TEMPLATE_NAME).") + @Option(names = {"--compute-jobs-machine-type"}, split = ",", paramLabel = "", description = "Comma-separated list of GCP machine types for compute jobs (e.g., n2-standard-8,c2-standard-4). Supports wildcard families (e.g., n2-*). Mutually exclusive with --compute-job-template.") + public List computeJobsMachineType; + + @Option(names = {"--compute-job-template"}, description = "Google Compute Engine instance template for pipeline compute jobs. Specify either the template name (if in the same project) or the fully qualified reference (projects/PROJECT_ID/global/instanceTemplates/TEMPLATE_NAME). Mutually exclusive with --compute-jobs-machine-type.") public String computeJobInstanceTemplate; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/credentials/providers/GoogleProvider.java b/src/main/java/io/seqera/tower/cli/commands/credentials/providers/GoogleProvider.java index 18aa0f76..faf99ce0 100644 --- a/src/main/java/io/seqera/tower/cli/commands/credentials/providers/GoogleProvider.java +++ b/src/main/java/io/seqera/tower/cli/commands/credentials/providers/GoogleProvider.java @@ -23,19 +23,90 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.regex.Pattern; public class GoogleProvider extends AbstractProvider { - @Option(names = {"-k", "--key"}, description = "Path to JSON file containing Google Cloud service account key. Download from Google Cloud Console IAM & Admin > Service Accounts.", required = true) + private static final Pattern SA_EMAIL_PATTERN = Pattern.compile( + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.iam\\.gserviceaccount\\.com$"); + + private static final Pattern WIF_PROVIDER_PATTERN = Pattern.compile( + "^projects/[^/]+/locations/global/workloadIdentityPools/[^/]+/providers/[^/]+$"); + + @Option(names = {"-k", "--key"}, description = "Path to JSON file containing Google Cloud service account key. Download from Google Cloud Console IAM & Admin > Service Accounts.") public Path serviceAccountKey; + @Option(names = {"--mode"}, description = "Google credential mode: 'service-account-key' (JSON key file) or 'workload-identity' (WIF with OIDC tokens). Default: service-account-key.") + String mode; + + @Option(names = {"--service-account-email"}, description = "The email address of the Google Cloud service account to impersonate (required for workload-identity mode).") + String serviceAccountEmail; + + @Option(names = {"--workload-identity-provider"}, description = "The full resource name of the Workload Identity Pool provider. Format: projects/{PROJECT}/locations/global/workloadIdentityPools/{POOL}/providers/{PROVIDER}") + String workloadIdentityProvider; + + @Option(names = {"--token-audience"}, description = "Optional. The intended audience for the OIDC token. If not specified, defaults to the Workload Identity Provider resource name.") + String tokenAudience; + public GoogleProvider() { super(ProviderEnum.GOOGLE); } @Override public GoogleSecurityKeys securityKeys() throws IOException { - return new GoogleSecurityKeys() - .data(FilesHelper.readString(serviceAccountKey)); + validate(); + + GoogleSecurityKeys result = new GoogleSecurityKeys(); + + if (isWorkloadIdentityMode()) { + result.serviceAccountEmail(serviceAccountEmail); + result.workloadIdentityProvider(workloadIdentityProvider); + if (tokenAudience != null) { + result.tokenAudience(tokenAudience); + } + } else { + result.data(FilesHelper.readString(serviceAccountKey)); + } + + return result; + } + + private boolean isWorkloadIdentityMode() { + if (mode == null) { + return false; + } + return switch (mode.toLowerCase()) { + case "service-account-key" -> false; + case "workload-identity" -> true; + default -> throw new IllegalArgumentException( + String.format("Invalid Google credential mode '%s'. Allowed values: 'service-account-key', 'workload-identity'.", mode)); + }; + } + + private void validate() { + if (isWorkloadIdentityMode()) { + if (serviceAccountKey != null) { + throw new IllegalArgumentException("Option '--key' cannot be used with '--mode=workload-identity'. Workload Identity mode uses federated authentication without a key file."); + } + if (serviceAccountEmail == null) { + throw new IllegalArgumentException("Option '--service-account-email' is required when using '--mode=workload-identity'."); + } + if (!SA_EMAIL_PATTERN.matcher(serviceAccountEmail).matches()) { + throw new IllegalArgumentException("Invalid service account email format. Expected format: @.iam.gserviceaccount.com"); + } + if (workloadIdentityProvider == null) { + throw new IllegalArgumentException("Option '--workload-identity-provider' is required when using '--mode=workload-identity'."); + } + if (!WIF_PROVIDER_PATTERN.matcher(workloadIdentityProvider).matches()) { + throw new IllegalArgumentException("Invalid Workload Identity Provider format. Expected: projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{POOL}/providers/{PROVIDER}"); + } + } else { + if (serviceAccountEmail != null || workloadIdentityProvider != null || tokenAudience != null) { + throw new IllegalArgumentException("Options '--service-account-email', '--workload-identity-provider', and '--token-audience' can only be used with '--mode=workload-identity'."); + } + if (serviceAccountKey == null) { + throw new IllegalArgumentException("Option '--key' is required when using service account key mode."); + } + } } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleBatchPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleBatchPlatformTest.java index 28cfc889..375eda1b 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleBatchPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleBatchPlatformTest.java @@ -30,6 +30,7 @@ import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME; 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; @@ -37,17 +38,23 @@ class GoogleBatchPlatformTest extends BaseCmdTest { + private static final String CREDENTIALS_RESPONSE = "{\"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\"}]}"; + + private void mockCredentials(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "google-batch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(CREDENTIALS_RESPONSE).withContentType(MediaType.APPLICATION_JSON) + ); + } + @ParameterizedTest @EnumSource(OutputType.class) void testAdd(OutputType format, MockServerClient mock) throws IOException { mock.reset(); - mock.when( - request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "google-batch"), exactly(1) - ).respond( - response().withStatusCode(200).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\"}]}").withContentType(MediaType.APPLICATION_JSON) - ); + mockCredentials(mock); mock.when( request().withMethod("POST").withPath("/compute-envs") @@ -65,11 +72,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { mock.reset(); - mock.when( - request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "google-batch"), exactly(1) - ).respond( - response().withStatusCode(200).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\"}]}").withContentType(MediaType.APPLICATION_JSON) - ); + mockCredentials(mock); mock.when( request().withMethod("POST").withPath("/compute-envs") @@ -89,11 +92,7 @@ void testAddWithInstanceTemplates(MockServerClient mock) throws IOException { mock.reset(); - mock.when( - request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "google-batch"), exactly(1) - ).respond( - response().withStatusCode(200).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\"}]}").withContentType(MediaType.APPLICATION_JSON) - ); + mockCredentials(mock); mock.when( request().withMethod("POST").withPath("/compute-envs") @@ -113,11 +112,7 @@ void testAddWithOnlyHeadJobTemplate(MockServerClient mock) throws IOException { mock.reset(); - mock.when( - request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "google-batch"), exactly(1) - ).respond( - response().withStatusCode(200).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\"}]}").withContentType(MediaType.APPLICATION_JSON) - ); + mockCredentials(mock); mock.when( request().withMethod("POST").withPath("/compute-envs") @@ -137,23 +132,338 @@ void testAddWithOnlyComputeJobTemplate(MockServerClient mock) throws IOException mock.reset(); + mockCredentials(mock); + mock.when( - request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "google-batch"), exactly(1) + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"computeJobsInstanceTemplate\":\"projects/my-project/global/instanceTemplates/compute-template\"}}}")), exactly(1) ).respond( - response().withStatusCode(200).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\"}]}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--compute-job-template", "projects/my-project/global/instanceTemplates/compute-template"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithNetworkTags(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + mock.when( request().withMethod("POST").withPath("/compute-envs") - .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"computeJobsInstanceTemplate\":\"projects/my-project/global/instanceTemplates/compute-template\"}}}")), exactly(1) + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"network\":\"my-vpc\",\"networkTags\":[\"allow-ssh\",\"web-tier\"]}}}")), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--compute-job-template", "projects/my-project/global/instanceTemplates/compute-template"); + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network", "my-vpc", "--network-tags", "allow-ssh,web-tier"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithNetworkAndSubnetwork(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"network\":\"my-vpc\",\"subnetwork\":\"my-subnet\"}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network", "my-vpc", "--subnetwork", "my-subnet"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddNetworkTagsWithoutVpcFails(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network-tags", "allow-ssh"); + + assertTrue(out.stdErr.contains("Network tags require VPC configuration"), "Expected VPC required error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddNetworkTagsInvalidFormat(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network", "my-vpc", "--network-tags", "Allow-SSH"); + + assertTrue(out.stdErr.contains("Invalid network tag 'Allow-SSH'"), "Expected invalid tag error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddNetworkTagsEndsWithHyphen(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network", "my-vpc", "--network-tags", "a-"); + + assertTrue(out.stdErr.contains("Invalid network tag 'a-'"), "Expected invalid tag error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddNetworkTagsSingleDigitInvalid(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network", "my-vpc", "--network-tags", "1"); + + assertTrue(out.stdErr.contains("Invalid network tag '1'"), "Expected invalid single-char tag error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddNetworkTagsSingleLetterValid(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"network\":\"my-vpc\",\"networkTags\":[\"a\"]}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--network", "my-vpc", "--network-tags", "a"); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithHeadJobMachineType(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"machineType\":\"n2-standard-4\"}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--head-job-machine-type", "n2-standard-4"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithComputeJobsMachineType(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"computeJobsMachineType\":[\"n2-standard-8\",\"c2-standard-4\"]}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--compute-jobs-machine-type", "n2-standard-8,c2-standard-4"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithComputeJobsWildcardMachineType(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"computeJobsMachineType\":[\"n2-*\"]}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", "--compute-jobs-machine-type", "n2-*"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddHeadJobMachineTypeAndTemplateAreMutuallyExclusive(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--head-job-machine-type", "n2-standard-4", + "--head-job-template", "projects/my-project/global/instanceTemplates/head-template"); + + assertTrue(out.stdErr.contains("Head job machine type and head job instance template are mutually exclusive"), "Expected mutual exclusivity error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddComputeJobsMachineTypeAndTemplateAreMutuallyExclusive(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--compute-jobs-machine-type", "n2-standard-8", + "--compute-job-template", "projects/my-project/global/instanceTemplates/compute-template"); + + assertTrue(out.stdErr.contains("Compute jobs machine type and compute jobs instance template are mutually exclusive"), "Expected mutual exclusivity error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddHeadJobWildcardMachineTypeRejected(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--head-job-machine-type", "n2-*"); + + assertTrue(out.stdErr.contains("Wildcard machine type families are not supported for the head job"), "Expected wildcard rejection error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddInvalidMachineTypeFormat(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--head-job-machine-type", "N2-Standard-4"); + + assertTrue(out.stdErr.contains("Invalid machine type 'N2-Standard-4'"), "Expected invalid format error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWithBootDiskImage(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"bootDiskImage\":\"projects/ubuntu-os-cloud/global/images/ubuntu-2404-noble-amd64-v20250112\"}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--boot-disk-image", "projects/ubuntu-os-cloud/global/images/ubuntu-2404-noble-amd64-v20250112"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); assertEquals(0, out.exitCode); } + @Test + void testAddWithBootDiskImageFamily(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"bootDiskImage\":\"projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts\"}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--boot-disk-image", "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts"); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithBootDiskImageBatchShortName(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":false,\"waveEnabled\":false,\"bootDiskImage\":\"batch-debian\"}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--boot-disk-image", "batch-debian"); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithInvalidBootDiskImage(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--boot-disk-image", "invalid/image/path"); + + assertTrue(out.stdErr.contains("Invalid boot disk image format"), "Expected invalid boot disk image error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWithFusionSnapshots(MockServerClient mock) throws IOException { + + mock.reset(); + + mockCredentials(mock); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"6XfOhoztUq6de3Dw3X9LSb\",\"name\":\"google\",\"platform\":\"google-batch\",\"config\":{\"location\":\"europe\",\"workDir\":\"gs://workdir\",\"fusion2Enabled\":true,\"fusionSnapshots\":true,\"waveEnabled\":true}}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--fusion-v2", "--wave", "--fusion-snapshots"); + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("google-batch", "isnEDBLvHDAIteOEF44ow", "google", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddFusionSnapshotsRequiresFusionV2(MockServerClient mock) { + + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-batch", "-n", "google", "--work-dir", "gs://workdir", "-l", "europe", + "--wave", "--fusion-snapshots"); + + assertTrue(out.stdErr.contains("Fusion Snapshots requires Fusion v2"), "Expected fusion v2 required error, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + } diff --git a/src/test/java/io/seqera/tower/cli/credentials/providers/GoogleProviderTest.java b/src/test/java/io/seqera/tower/cli/credentials/providers/GoogleProviderTest.java index b06b3b63..7c7eaf38 100644 --- a/src/test/java/io/seqera/tower/cli/credentials/providers/GoogleProviderTest.java +++ b/src/test/java/io/seqera/tower/cli/credentials/providers/GoogleProviderTest.java @@ -33,6 +33,7 @@ import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME; 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; @@ -58,6 +59,154 @@ void testAdd(OutputType format, MockServerClient mock) throws IOException { assertOutput(format, out, new CredentialsAdded("GOOGLE", "1cz5A8cuBkB5iJliCwJCFU", "google", USER_WORKSPACE_NAME)); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testAddWithExplicitServiceAccountKeyMode(OutputType format, MockServerClient mock) throws IOException { + + mock.when( + request() + .withMethod("POST") + .withPath("/credentials") + .withBody(json("{\"credentials\":{\"keys\":{\"data\":\"private_key\"},\"name\":\"google-sa\",\"provider\":\"google\"}}")), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentialsId\":\"2cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "credentials", "add", "google", "-n", "google-sa", "--mode=service-account-key", "-k", tempFile("private_key", "id_rsa", "")); + assertOutput(format, out, new CredentialsAdded("GOOGLE", "2cz5A8cuBkB5iJliCwJCFU", "google-sa", USER_WORKSPACE_NAME)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAddWithWorkloadIdentityMode(OutputType format, MockServerClient mock) { + + mock.when( + request() + .withMethod("POST") + .withPath("/credentials") + .withBody(json("{\"credentials\":{\"keys\":{\"serviceAccountEmail\":\"my-sa@my-project.iam.gserviceaccount.com\",\"workloadIdentityProvider\":\"projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider\"},\"name\":\"google-wif\",\"provider\":\"google\"}}")), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentialsId\":\"3cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "credentials", "add", "google", "-n", "google-wif", + "--mode=workload-identity", + "--service-account-email=my-sa@my-project.iam.gserviceaccount.com", + "--workload-identity-provider=projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider"); + assertOutput(format, out, new CredentialsAdded("GOOGLE", "3cz5A8cuBkB5iJliCwJCFU", "google-wif", USER_WORKSPACE_NAME)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAddWithWorkloadIdentityModeAndTokenAudience(OutputType format, MockServerClient mock) { + + mock.when( + request() + .withMethod("POST") + .withPath("/credentials") + .withBody(json("{\"credentials\":{\"keys\":{\"serviceAccountEmail\":\"my-sa@my-project.iam.gserviceaccount.com\",\"workloadIdentityProvider\":\"projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider\",\"tokenAudience\":\"https://my-audience.example.com\"},\"name\":\"google-wif-aud\",\"provider\":\"google\"}}")), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentialsId\":\"4cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "credentials", "add", "google", "-n", "google-wif-aud", + "--mode=workload-identity", + "--service-account-email=my-sa@my-project.iam.gserviceaccount.com", + "--workload-identity-provider=projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider", + "--token-audience=https://my-audience.example.com"); + assertOutput(format, out, new CredentialsAdded("GOOGLE", "4cz5A8cuBkB5iJliCwJCFU", "google-wif-aud", USER_WORKSPACE_NAME)); + } + + @Test + void testAddWorkloadIdentityModeRejectsKeyFile(MockServerClient mock) throws IOException { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", + "--mode=workload-identity", + "-k", tempFile("private_key", "id_rsa", ""), + "--service-account-email=my-sa@my-project.iam.gserviceaccount.com", + "--workload-identity-provider=projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider"); + + assertTrue(out.stdErr.contains("'--key' cannot be used with '--mode=workload-identity'"), "Expected error about key not allowed in WIF mode, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWorkloadIdentityModeRequiresServiceAccountEmail(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", + "--mode=workload-identity", + "--workload-identity-provider=projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider"); + + assertTrue(out.stdErr.contains("'--service-account-email' is required when using '--mode=workload-identity'"), "Expected error about missing service-account-email, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWorkloadIdentityModeRequiresWorkloadIdentityProvider(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", + "--mode=workload-identity", + "--service-account-email=my-sa@my-project.iam.gserviceaccount.com"); + + assertTrue(out.stdErr.contains("'--workload-identity-provider' is required when using '--mode=workload-identity'"), "Expected error about missing workload-identity-provider, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWorkloadIdentityModeInvalidServiceAccountEmail(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", + "--mode=workload-identity", + "--service-account-email=invalid-email@gmail.com", + "--workload-identity-provider=projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider"); + + assertTrue(out.stdErr.contains("Invalid service account email format"), "Expected error about invalid email format, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWorkloadIdentityModeInvalidProviderFormat(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", + "--mode=workload-identity", + "--service-account-email=my-sa@my-project.iam.gserviceaccount.com", + "--workload-identity-provider=invalid/provider/path"); + + assertTrue(out.stdErr.contains("Invalid Workload Identity Provider format"), "Expected error about invalid provider format, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddServiceAccountKeyModeRejectsWifOptions(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", + "--service-account-email=my-sa@my-project.iam.gserviceaccount.com"); + + assertTrue(out.stdErr.contains("can only be used with '--mode=workload-identity'"), "Expected error about WIF options in wrong mode, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddServiceAccountKeyModeRequiresKeyFile(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad"); + + assertTrue(out.stdErr.contains("'--key' is required"), "Expected error about missing key file, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testAddInvalidMode(MockServerClient mock) { + + ExecOut out = exec(mock, "credentials", "add", "google", "-n", "google-bad", "--mode=invalid"); + + assertTrue(out.stdErr.contains("Invalid Google credential mode 'invalid'"), "Expected error about invalid mode, got: " + out.stdErr); + assertEquals(1, out.exitCode); + } + @Test void testFileNotFound(MockServerClient mock) { From b42ec18b56db6ddd2592a3cc47c8ecee9e1243cc Mon Sep 17 00:00:00 2001 From: ramonamela <25862624+ramonamela@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:35:16 +0200 Subject: [PATCH 2/2] chore: bump tower-java-sdk to 1.133.0 and add integration test script - Upgrade tower-java-sdk from 1.114.0 to 1.133.0 to include new fields on GoogleSecurityKeys and GoogleBatchConfig - Add bash integration test script for all COMP-1463 features Co-Authored-By: Claude Opus 4.6 (1M context) --- gradle/libs.versions.toml | 2 +- .../scripts/test_google_batch_features.sh | 406 ++++++++++++++++++ 2 files changed, 407 insertions(+), 1 deletion(-) create mode 100755 src/test/resources/scripts/test_google_batch_features.sh diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89834aa4..26da64af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ mockserverVersion = "5.15.0" picocliVersion = "4.6.3" shadowVersion = "9.3.1" slf4jVersion = "2.0.17" -towerJavaSdkVersion = "1.114.0" +towerJavaSdkVersion = "1.133.0" xzVersion = "1.10" [libraries] diff --git a/src/test/resources/scripts/test_google_batch_features.sh b/src/test/resources/scripts/test_google_batch_features.sh new file mode 100755 index 00000000..2cbf8d2e --- /dev/null +++ b/src/test/resources/scripts/test_google_batch_features.sh @@ -0,0 +1,406 @@ +#!/usr/bin/env bash +# +# Integration test script for COMP-1463: new Google Batch CE and credential features. +# +# Prerequisites: +# - TOWER_ACCESS_TOKEN is set +# - TOWER_API_ENDPOINT is set (e.g., https://api.cloud.seqera.io) +# - TOWER_WORKSPACE_ID is set (target workspace) +# - tw CLI is available on PATH (or set TW_CMD to the path) +# - A Google credential named $GOOGLE_CRED_NAME exists or will be created +# +# Usage: +# export TOWER_ACCESS_TOKEN= +# export TOWER_API_ENDPOINT= +# export TOWER_WORKSPACE_ID= +# bash test_google_batch_features.sh +# + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration — override via environment variables +# --------------------------------------------------------------------------- +TW="${TW_CMD:-tw}" +WORKSPACE_FLAG="--workspace=${TOWER_WORKSPACE_ID:?TOWER_WORKSPACE_ID is required}" + +# Google Batch CE defaults +GCP_LOCATION="${GCP_LOCATION:-europe-west1}" +GCP_WORK_DIR="${GCP_WORK_DIR:?GCP_WORK_DIR is required (e.g., gs://your-bucket/work)}" +GCP_NETWORK="${GCP_NETWORK:-default}" + +# WIF credential test values +WIF_SA_EMAIL="${WIF_SA_EMAIL:-my-sa@my-project.iam.gserviceaccount.com}" +WIF_PROVIDER="${WIF_PROVIDER:-projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider}" + +# Naming +PREFIX="comp1463-test-$(date +%s)" +CRED_SA_NAME="${PREFIX}-google-sa" +CRED_WIF_NAME="${PREFIX}-google-wif" +CE_BASE_NAME="${PREFIX}-ce" + +# Counters +PASSED=0 +FAILED=0 +SKIPPED=0 +ERRORS=() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { echo "--- $*"; } +pass() { ((PASSED++)); echo " PASS: $1"; } +fail() { ((FAILED++)); ERRORS+=("$1"); echo " FAIL: $1"; } +skip() { ((SKIPPED++)); echo " SKIP: $1"; } + +# Run a tw command and capture exit code + stderr +run_tw() { + local description="$1"; shift + local stderr_file + stderr_file=$(mktemp) + local exit_code=0 + "$TW" "$@" 2>"$stderr_file" || exit_code=$? + LAST_STDERR=$(cat "$stderr_file") + rm -f "$stderr_file" + LAST_EXIT=$exit_code + return 0 +} + +# Expect success (exit 0) +expect_success() { + local desc="$1"; shift + run_tw "$desc" "$@" + if [[ $LAST_EXIT -eq 0 ]]; then + pass "$desc" + else + fail "$desc (exit=$LAST_EXIT, stderr=$LAST_STDERR)" + fi +} + +# Expect failure (exit != 0) with a specific error substring +expect_failure() { + local desc="$1"; shift + local expected_error="$1"; shift + run_tw "$desc" "$@" + if [[ $LAST_EXIT -ne 0 ]]; then + if echo "$LAST_STDERR" | grep -q "$expected_error"; then + pass "$desc" + else + fail "$desc (expected error containing '$expected_error', got: $LAST_STDERR)" + fi + else + fail "$desc (expected failure but got exit 0)" + fi +} + +# Cleanup helper — delete resource, ignore failures +cleanup_cred() { + "$TW" credentials delete -n "$1" "$WORKSPACE_FLAG" 2>/dev/null || true +} + +cleanup_ce() { + "$TW" compute-envs delete -n "$1" "$WORKSPACE_FLAG" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Cleanup trap +# --------------------------------------------------------------------------- +cleanup() { + log "Cleaning up test resources..." + cleanup_cred "$CRED_SA_NAME" + cleanup_cred "$CRED_WIF_NAME" + # CEs created during tests + for suffix in base nettags nettags-subnet machine-types machine-wildcard \ + boot-image fusion-snap all-features; do + cleanup_ce "${CE_BASE_NAME}-${suffix}" + done + log "Cleanup complete." +} +trap cleanup EXIT + +# =========================================================================== +# TEST SUITE +# =========================================================================== + +log "=== COMP-1463 Integration Tests ===" +log "Endpoint: ${TOWER_API_ENDPOINT}" +log "Workspace: ${TOWER_WORKSPACE_ID}" +log "" + +# --------------------------------------------------------------------------- +# 1. GOOGLE CREDENTIALS — WIF MODE +# --------------------------------------------------------------------------- +log "=== 1. Google Credentials — WIF Mode ===" + +# 1.1 Add WIF credentials (happy path) +expect_success "Add WIF credentials" \ + credentials add google -n "$CRED_WIF_NAME" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + --service-account-email="$WIF_SA_EMAIL" \ + --workload-identity-provider="$WIF_PROVIDER" + +# 1.2 Add WIF credentials with token audience +cleanup_cred "$CRED_WIF_NAME" +expect_success "Add WIF credentials with token audience" \ + credentials add google -n "$CRED_WIF_NAME" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + --service-account-email="$WIF_SA_EMAIL" \ + --workload-identity-provider="$WIF_PROVIDER" \ + --token-audience="https://custom-audience.example.com" + +# 1.3 Validation: WIF mode without service-account-email +expect_failure "WIF mode rejects missing service-account-email" \ + "'--service-account-email' is required" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + --workload-identity-provider="$WIF_PROVIDER" + +# 1.4 Validation: WIF mode without workload-identity-provider +expect_failure "WIF mode rejects missing workload-identity-provider" \ + "'--workload-identity-provider' is required" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + --service-account-email="$WIF_SA_EMAIL" + +# 1.5 Validation: invalid service account email format +expect_failure "WIF mode rejects invalid email format" \ + "Invalid service account email format" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + --service-account-email="bad-email@gmail.com" \ + --workload-identity-provider="$WIF_PROVIDER" + +# 1.6 Validation: invalid provider format +expect_failure "WIF mode rejects invalid provider format" \ + "Invalid Workload Identity Provider format" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + --service-account-email="$WIF_SA_EMAIL" \ + --workload-identity-provider="invalid/path" + +# 1.7 Validation: WIF options in SA key mode +expect_failure "SA key mode rejects WIF options" \ + "can only be used with '--mode=workload-identity'" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --service-account-email="$WIF_SA_EMAIL" + +# 1.8 Validation: key file in WIF mode +expect_failure "WIF mode rejects --key option" \ + "'--key' cannot be used with '--mode=workload-identity'" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --mode=workload-identity \ + -k /dev/null \ + --service-account-email="$WIF_SA_EMAIL" \ + --workload-identity-provider="$WIF_PROVIDER" + +# 1.9 Validation: invalid mode +expect_failure "Rejects invalid mode" \ + "Invalid Google credential mode" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" \ + --mode=invalid + +# 1.10 Validation: SA key mode requires --key +expect_failure "SA key mode requires --key" \ + "'--key' is required" \ + credentials add google -n "${CRED_WIF_NAME}-bad" "$WORKSPACE_FLAG" + +echo "" + +# --------------------------------------------------------------------------- +# 2. GOOGLE BATCH CE — NETWORK TAGS +# --------------------------------------------------------------------------- +log "=== 2. Google Batch CE — Network Tags ===" + +# 2.1 CE with network tags (happy path) +expect_success "Add CE with network tags" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-nettags" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --network="$GCP_NETWORK" \ + --network-tags="allow-ssh,web-tier" + +# 2.2 CE with network + subnetwork +expect_success "Add CE with network and subnetwork" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-nettags-subnet" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --network="$GCP_NETWORK" \ + --subnetwork="default" + +# 2.3 Validation: tags without VPC +expect_failure "Network tags require VPC" \ + "Network tags require VPC configuration" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --network-tags="allow-ssh" + +# 2.4 Validation: invalid tag format (uppercase) +expect_failure "Rejects uppercase network tag" \ + "Invalid network tag" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --network="$GCP_NETWORK" \ + --network-tags="Allow-SSH" + +# 2.5 Validation: tag ending with hyphen +expect_failure "Rejects tag ending with hyphen" \ + "Invalid network tag" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --network="$GCP_NETWORK" \ + --network-tags="a-" + +# 2.6 Single lowercase letter tag (valid) +expect_success "Accepts single letter tag" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-nettags-single" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --network="$GCP_NETWORK" \ + --network-tags="a" +cleanup_ce "${CE_BASE_NAME}-nettags-single" + +echo "" + +# --------------------------------------------------------------------------- +# 3. GOOGLE BATCH CE — MACHINE TYPES +# --------------------------------------------------------------------------- +log "=== 3. Google Batch CE — Machine Types ===" + +# 3.1 Head job machine type +expect_success "Add CE with head job machine type" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-machine-types" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --head-job-machine-type="n2-standard-4" + +# 3.2 Compute jobs machine types (multiple) +expect_success "Add CE with compute jobs machine types" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-machine-multi" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --compute-jobs-machine-type="n2-standard-8,c2-standard-4" +cleanup_ce "${CE_BASE_NAME}-machine-multi" + +# 3.3 Wildcard machine type for compute jobs +expect_success "Add CE with wildcard compute machine type" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-machine-wildcard" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --compute-jobs-machine-type="n2-*" + +# 3.4 Validation: head machine type + template mutually exclusive +expect_failure "Head machine type and template are mutually exclusive" \ + "mutually exclusive" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --head-job-machine-type="n2-standard-4" \ + --head-job-template="projects/p/global/instanceTemplates/t" + +# 3.5 Validation: compute machine type + template mutually exclusive +expect_failure "Compute machine type and template are mutually exclusive" \ + "mutually exclusive" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --compute-jobs-machine-type="n2-standard-8" \ + --compute-job-template="projects/p/global/instanceTemplates/t" + +# 3.6 Validation: wildcard rejected for head job +expect_failure "Head job rejects wildcard machine type" \ + "Wildcard machine type families are not supported for the head job" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --head-job-machine-type="n2-*" + +# 3.7 Validation: invalid machine type format +expect_failure "Rejects invalid machine type format" \ + "Invalid machine type" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --head-job-machine-type="N2-Standard-4" + +echo "" + +# --------------------------------------------------------------------------- +# 4. GOOGLE BATCH CE — BOOT DISK IMAGE +# --------------------------------------------------------------------------- +log "=== 4. Google Batch CE — Boot Disk Image ===" + +# 4.1 Full image path +expect_success "Add CE with boot disk image (full path)" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-boot-image" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --boot-disk-image="projects/ubuntu-os-cloud/global/images/ubuntu-2404-noble-amd64-v20250112" + +# 4.2 Image family path +expect_success "Add CE with boot disk image (family)" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-boot-family" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --boot-disk-image="projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts" +cleanup_ce "${CE_BASE_NAME}-boot-family" + +# 4.3 Batch short name +expect_success "Add CE with boot disk image (batch short name)" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-boot-batch" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --boot-disk-image="batch-debian" +cleanup_ce "${CE_BASE_NAME}-boot-batch" + +# 4.4 Validation: invalid image format +expect_failure "Rejects invalid boot disk image" \ + "Invalid boot disk image format" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --boot-disk-image="not/a/valid/image" + +echo "" + +# --------------------------------------------------------------------------- +# 5. GOOGLE BATCH CE — FUSION SNAPSHOTS +# --------------------------------------------------------------------------- +log "=== 5. Google Batch CE — Fusion Snapshots ===" + +# 5.1 Fusion snapshots with Fusion v2 + Wave +expect_success "Add CE with Fusion Snapshots" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-fusion-snap" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --fusion-v2 --wave --fusion-snapshots + +# 5.2 Validation: snapshots without Fusion v2 +expect_failure "Fusion Snapshots requires Fusion v2" \ + "Fusion Snapshots requires Fusion v2" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-bad" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --wave --fusion-snapshots + +echo "" + +# --------------------------------------------------------------------------- +# 6. COMBINED — ALL FEATURES TOGETHER +# --------------------------------------------------------------------------- +log "=== 6. Combined — All Features Together ===" + +expect_success "Add CE with all new features combined" \ + compute-envs add google-batch -n "${CE_BASE_NAME}-all-features" "$WORKSPACE_FLAG" \ + --work-dir="$GCP_WORK_DIR" -l "$GCP_LOCATION" \ + --fusion-v2 --wave --fusion-snapshots \ + --network="$GCP_NETWORK" \ + --network-tags="allow-ssh,web-tier" \ + --compute-jobs-machine-type="n2-standard-8,c2-standard-4" \ + --head-job-machine-type="n2-standard-4" \ + --boot-disk-image="batch-debian" + +echo "" + +# =========================================================================== +# RESULTS +# =========================================================================== +log "=== Test Results ===" +echo " Passed: $PASSED" +echo " Failed: $FAILED" +echo " Skipped: $SKIPPED" +echo "" + +if [[ $FAILED -gt 0 ]]; then + log "=== Failed Tests ===" + for err in "${ERRORS[@]}"; do + echo " - $err" + done + echo "" + exit 1 +fi + +log "All tests passed."