Skip to content
Closed
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 gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GoogleBatchConfig> {

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;

Expand All @@ -38,6 +46,9 @@ public class GoogleBatchPlatform extends AbstractPlatform<GoogleBatchConfig> {
@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;

Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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<String> 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 = "<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;

@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;

Expand All @@ -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 = "<type>", 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<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,90 @@

import java.io.IOException;
import java.nio.file.Path;
import java.util.regex.Pattern;

public class GoogleProvider extends AbstractProvider<GoogleSecurityKeys> {

@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: <name>@<project>.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.");
}
}
}
}
Loading
Loading