Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION-API
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
1.181.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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,9 +26,14 @@

import java.io.IOException;
import java.util.List;
import java.util.regex.Pattern;

public class GoogleCloudPlatform extends AbstractPlatform<GoogleCloudConfig> {

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;

Expand Down Expand Up @@ -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
Expand All @@ -99,6 +113,31 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException {
return config;
}

private static void validateNetworkTags(List<String> 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;
Expand All @@ -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 = "<subnetwork>", 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<String> subnetworks;

@Option(names = {"--network-tags"}, split = ",", paramLabel = "<tag>", 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<String> 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;
}
}
4 changes: 2 additions & 2 deletions src/test/java/io/seqera/tower/cli/InfoCmdTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ void testInfo(OutputType format, MockServerClient mock) throws IOException {
Map<String, String> opts = new HashMap<>();
opts.put("cliVersion", getCliVersion() );
opts.put("cliApiVersion", getCliApiVersion());
opts.put("towerApiVersion", "1.181.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");
Expand Down Expand Up @@ -86,7 +86,7 @@ void testInfoStatusTokenFail(MockServerClient mock) throws IOException {
Map<String, String> opts = new HashMap<>();
opts.put("cliVersion", getCliVersion() );
opts.put("cliApiVersion", getCliApiVersion());
opts.put("towerApiVersion", "1.181.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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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\"}]}")
);
}
}
2 changes: 1 addition & 1 deletion src/test/resources/runcmd/info/service-info.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"serviceInfo": {
"version": "22.3.0-torricelli",
"apiVersion": "1.181.0",
"apiVersion": "1.189.0",
"commitId": "3f04bfd4",
"authTypes": [
"github",
Expand Down
Loading