From 3f920870fc3c27f339f61f7a1cfc403fe0c28b13 Mon Sep 17 00:00:00 2001 From: Jon Marti Date: Fri, 31 Jul 2026 18:15:51 +0200 Subject: [PATCH 1/4] feat: [COMP-2106] support VPC network and subnetworks for google-cloud compute environment Adds --network, --subnetworks, --network-tags and --use-private-address advanced options to the 'compute-envs add google-cloud' command, mirroring the aws-cloud VPC/subnet support. Subnetworks is a list (Intelligent Compute may spread workers across all of them; the first is used for basic placement), matching the GoogleCloudConfig model. Network tags are validated CLI-side (require a network, GCP tag format, max count) as for google-batch. Co-Authored-By: Claude Opus 4.8 --- .../platforms/GoogleCloudPlatform.java | 53 ++++++- .../platforms/GoogleCloudPlatformTest.java | 149 ++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) 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 fca9f4f6..43879b8a 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 @@ -17,6 +17,7 @@ package io.seqera.tower.cli.commands.computeenvs.platforms; import io.seqera.tower.ApiException; +import io.seqera.tower.cli.exceptions.TowerRuntimeException; import io.seqera.tower.model.ComputeEnvComputeConfig.PlatformEnum; import io.seqera.tower.model.GoogleCloudConfig; import io.seqera.tower.model.SchedConfig; @@ -25,9 +26,14 @@ import java.io.IOException; import java.util.List; +import java.util.regex.Pattern; public class GoogleCloudPlatform extends AbstractPlatform { + private static final Pattern NETWORK_TAG_PATTERN = Pattern.compile("^[a-z][-a-z0-9]*[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). Credentials must have read-write access.", required = true) public String workDir; @@ -81,12 +87,20 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { // Advanced if (adv != null) { + if (adv.networkTags != null && !adv.networkTags.isEmpty()) { + validateNetworkTags(adv.networkTags, adv.network); + } + config .arm64Enabled(adv.arm64Enabled) .gpuEnabled(adv.gpuEnabled) .imageId(adv.imageId) .instanceType(adv.instanceType) - .bootDiskSizeGb(adv.bootDiskSizeGb); + .bootDiskSizeGb(adv.bootDiskSizeGb) + .network(adv.network) + .subnetworks(adv.subnetworks) + .networkTags(adv.networkTags) + .usePrivateAddress(adv.usePrivateAddress); } // Common @@ -99,6 +113,31 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { return config; } + private static void validateNetworkTags(List tags, String network) { + if (network == null || network.isEmpty()) { + throw new TowerRuntimeException("Network tags require VPC configuration: set the '--network' option to use network tags."); + } + + if (tags.size() > MAX_NETWORK_TAGS) { + throw new TowerRuntimeException(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 TowerRuntimeException(String.format("Invalid network tag '%s': must be 1-63 characters.", tag)); + } + if (tag.length() == 1) { + if (!tag.matches("^[a-z]$")) { + throw new TowerRuntimeException(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 TowerRuntimeException(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 SchedOptions { @Option(names = {"--sched-enabled"}, description = "Enable the Seqera scheduler for this compute environment. Defaults to false if not specified.") public Boolean schedEnabled; @@ -125,5 +164,17 @@ public static class AdvancedOptions { @Option(names = {"--instance-type"}, description = "Compute Engine machine type (e.g., n1-standard-1, n2-standard-2). If omitted, a default machine type is used.") public String instanceType; + + @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 = {"--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; + + @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; } } 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 99b9938b..3f0ae14f 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 @@ -29,6 +29,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; @@ -230,4 +231,152 @@ void testAddWithScheduler(MockServerClient mock) throws IOException { assertEquals(0, out.exitCode); assertEquals(expected.toString(), out.stdOut); } + + @Test + void testAddWithNetworkAndSubnetworks(MockServerClient mock) throws IOException { + mock.reset(); + mockCredentials(mock); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-google-cloud-net", + "platform": "google-cloud", + "config": { + "workDir": "gs://my-bucket", + "region": "us-central1", + "zone": "us-central1-a", + "fusion2Enabled": true, + "waveEnabled": true, + "network": "my-vpc", + "subnetworks": ["subnet-a", "subnet-b"], + "usePrivateAddress": true + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-cloud", + "-n", "my-google-cloud-net", + "--work-dir", "gs://my-bucket", + "-r", "us-central1", + "-z", "us-central1-a", + "--network", "my-vpc", + "--subnetworks", "subnet-a,subnet-b", + "--use-private-address" + ); + + var expected = new ComputeEnvAdded("google-cloud", "isnEDBLvHDAIteOEF44ow", "my-google-cloud-net", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } + + @Test + void testAddWithNetworkTags(MockServerClient mock) throws IOException { + mock.reset(); + mockCredentials(mock); + + mock.when( + request() + .withMethod("POST") + .withPath("/compute-envs") + .withBody(json(""" + { + "computeEnv": { + "name": "my-google-cloud-tags", + "platform": "google-cloud", + "config": { + "workDir": "gs://my-bucket", + "region": "us-central1", + "zone": "us-central1-a", + "fusion2Enabled": true, + "waveEnabled": true, + "network": "my-vpc", + "networkTags": ["allow-ssh", "web-tier"] + }, + "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" + } + }""")), + exactly(1) + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}") + ); + + ExecOut out = exec(mock, "compute-envs", "add", "google-cloud", + "-n", "my-google-cloud-tags", + "--work-dir", "gs://my-bucket", + "-r", "us-central1", + "-z", "us-central1-a", + "--network", "my-vpc", + "--network-tags", "allow-ssh,web-tier" + ); + + var expected = new ComputeEnvAdded("google-cloud", "isnEDBLvHDAIteOEF44ow", "my-google-cloud-tags", null, USER_WORKSPACE_NAME); + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(expected.toString(), out.stdOut); + } + + @Test + void testAddNetworkTagsWithoutNetworkFails(MockServerClient mock) { + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-cloud", + "-n", "my-google-cloud-tags", + "--work-dir", "gs://my-bucket", + "-r", "us-central1", + "-z", "us-central1-a", + "--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 testAddNetworkTagsInvalidFormatFails(MockServerClient mock) { + mock.reset(); + + ExecOut out = exec(mock, "compute-envs", "add", "google-cloud", + "-n", "my-google-cloud-tags", + "--work-dir", "gs://my-bucket", + "-r", "us-central1", + "-z", "us-central1-a", + "--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); + } + + private static void mockCredentials(MockServerClient mock) { + 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\"}]}") + ); + } } From e8bda08e8b50f81f09d56976d369c54bd5a7f6d5 Mon Sep 17 00:00:00 2001 From: Jon Marti Date: Fri, 31 Jul 2026 18:40:23 +0200 Subject: [PATCH 2/4] chore: bump minimum API version to 1.190.0 for google-cloud VPC support The new --network/--subnetworks/--network-tags/--use-private-address options require GoogleCloudConfig network fields, introduced in Platform API 1.190.0 (current cloud production version). Co-Authored-By: Claude Opus 4.8 --- VERSION-API | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION-API b/VERSION-API index 9e85495b..f5cdd47d 100644 --- a/VERSION-API +++ b/VERSION-API @@ -1,4 +1,4 @@ -1.181.0 +1.190.0 // Only first line of this file is read // This version should be bumped to the minimum version where dependent API changes were introduced // But never higher then the current Platform API Version deployed in Cloud Production: https://cloud.seqera.io/api/service-info \ No newline at end of file From 9647133f3d06d190e01bc9af0c16291dc19a4bf1 Mon Sep 17 00:00:00 2001 From: Jon Marti Date: Fri, 31 Jul 2026 18:43:27 +0200 Subject: [PATCH 3/4] test: update info command version fixtures to 1.190.0 Follows the VERSION-API bump: the mocked backend version in InfoCmdTest (and its service-info fixture) must be >= the CLI minimum, otherwise the info command reports the backend as older than the minimum. Co-Authored-By: Claude Opus 4.8 --- src/test/java/io/seqera/tower/cli/InfoCmdTest.java | 4 ++-- src/test/resources/runcmd/info/service-info.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java index 3dd5fd01..02fc1621 100644 --- a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java @@ -56,7 +56,7 @@ void testInfo(OutputType format, MockServerClient mock) throws IOException { Map opts = new HashMap<>(); opts.put("cliVersion", getCliVersion() ); opts.put("cliApiVersion", getCliApiVersion()); - opts.put("towerApiVersion", "1.181.0"); + opts.put("towerApiVersion", "1.190.0"); opts.put("towerVersion", "22.3.0-torricelli"); opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort()); opts.put("userName", "jordi"); @@ -86,7 +86,7 @@ void testInfoStatusTokenFail(MockServerClient mock) throws IOException { Map opts = new HashMap<>(); opts.put("cliVersion", getCliVersion() ); opts.put("cliApiVersion", getCliApiVersion()); - opts.put("towerApiVersion", "1.181.0"); + opts.put("towerApiVersion", "1.190.0"); opts.put("towerVersion", "22.3.0-torricelli"); opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort()); opts.put("userName", null); diff --git a/src/test/resources/runcmd/info/service-info.json b/src/test/resources/runcmd/info/service-info.json index 6ed45847..cd2aefd6 100644 --- a/src/test/resources/runcmd/info/service-info.json +++ b/src/test/resources/runcmd/info/service-info.json @@ -1,7 +1,7 @@ { "serviceInfo": { "version": "22.3.0-torricelli", - "apiVersion": "1.181.0", + "apiVersion": "1.190.0", "commitId": "3f04bfd4", "authTypes": [ "github", From fcd870176b5e44a554932ef8d1114f3d1fd6d7fe Mon Sep 17 00:00:00 2001 From: Jon Marti Date: Sun, 2 Aug 2026 17:41:24 +0200 Subject: [PATCH 4/4] fix: set minimum API version to 1.189.0 for google-cloud VPC support The GoogleCloudConfig network/subnetworks/networkTags/usePrivateAddress fields were introduced in Platform API 1.189.0 (per changelog-API.md), not 1.190.0. Correct VERSION-API and the info command version fixtures. Co-Authored-By: Claude Opus 4.8 --- VERSION-API | 2 +- src/test/java/io/seqera/tower/cli/InfoCmdTest.java | 4 ++-- src/test/resources/runcmd/info/service-info.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION-API b/VERSION-API index f5cdd47d..a661761e 100644 --- a/VERSION-API +++ b/VERSION-API @@ -1,4 +1,4 @@ -1.190.0 +1.189.0 // Only first line of this file is read // This version should be bumped to the minimum version where dependent API changes were introduced // But never higher then the current Platform API Version deployed in Cloud Production: https://cloud.seqera.io/api/service-info \ No newline at end of file diff --git a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java index 02fc1621..46063375 100644 --- a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java @@ -56,7 +56,7 @@ void testInfo(OutputType format, MockServerClient mock) throws IOException { Map opts = new HashMap<>(); opts.put("cliVersion", getCliVersion() ); opts.put("cliApiVersion", getCliApiVersion()); - opts.put("towerApiVersion", "1.190.0"); + opts.put("towerApiVersion", "1.189.0"); opts.put("towerVersion", "22.3.0-torricelli"); opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort()); opts.put("userName", "jordi"); @@ -86,7 +86,7 @@ void testInfoStatusTokenFail(MockServerClient mock) throws IOException { Map opts = new HashMap<>(); opts.put("cliVersion", getCliVersion() ); opts.put("cliApiVersion", getCliApiVersion()); - opts.put("towerApiVersion", "1.190.0"); + opts.put("towerApiVersion", "1.189.0"); opts.put("towerVersion", "22.3.0-torricelli"); opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort()); opts.put("userName", null); diff --git a/src/test/resources/runcmd/info/service-info.json b/src/test/resources/runcmd/info/service-info.json index cd2aefd6..7d2b3b31 100644 --- a/src/test/resources/runcmd/info/service-info.json +++ b/src/test/resources/runcmd/info/service-info.json @@ -1,7 +1,7 @@ { "serviceInfo": { "version": "22.3.0-torricelli", - "apiVersion": "1.190.0", + "apiVersion": "1.189.0", "commitId": "3f04bfd4", "authTypes": [ "github",