From c8291682bed77ab60c213711da1555d4eacc45ab Mon Sep 17 00:00:00 2001 From: ramonamela <25862624+ramonamela@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:41:19 +0200 Subject: [PATCH 1/3] feat(lib-cloudinfo): add ProductsQuery for sched/nvme filters Expose the sched and nvme query parameters introduced in seqeralabs/cloudinfo#48 via a new ProductsQuery options object and a getProducts(provider, region, ProductsQuery) overload on CloudInfoClient. The existing two-argument getProducts method is preserved and now delegates to the new overload. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib-cloudinfo/README.md | 19 ++- .../seqera/cloudinfo/api/ProductsQuery.java | 111 ++++++++++++++++++ .../cloudinfo/client/CloudInfoClient.java | 38 +++++- .../client/CloudInfoClientTest.groovy | 52 ++++++++ 4 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java diff --git a/lib-cloudinfo/README.md b/lib-cloudinfo/README.md index ae81c161..b33dd44f 100644 --- a/lib-cloudinfo/README.md +++ b/lib-cloudinfo/README.md @@ -10,7 +10,7 @@ Add the dependency to your `build.gradle`: ```gradle dependencies { - implementation 'io.seqera:lib-cloudinfo:1.0.0' + implementation 'io.seqera:lib-cloudinfo:1.1.0' } ``` @@ -39,6 +39,22 @@ List regionIds = client.getRegionIds("amazon"); List products = client.getProducts("amazon", "us-east-1"); ``` +### Filtering Products + +Pass a `ProductsQuery` to restrict the products endpoint to instance families +with Scheduler support (`sched=true`) or NVMe local storage (`nvme=true`). +Filters are applied server-side and can be combined; providers without filter +config entries return all products unfiltered. + +```java +ProductsQuery query = ProductsQuery.builder() + .sched(true) + .nvme(true) + .build(); + +List products = client.getProducts("amazon", "us-east-1", query); +``` + ### Custom Configuration ```java @@ -66,6 +82,7 @@ CloudInfoClient client = CloudInfoClient.builder() | `CloudPrice` | Spot price for a specific zone | | `CloudResponse` | API response wrapper containing products | | `ProductAttributes` | Additional product attributes | +| `ProductsQuery` | Optional filters (`sched`, `nvme`) for the products endpoint | ## Dependencies diff --git a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java new file mode 100644 index 00000000..897b8931 --- /dev/null +++ b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.cloudinfo.api; + +/** + * Optional query parameters for the Cloudinfo products endpoint. + * + *

Each flag is applied server-side; unset flags (the default) leave the + * corresponding filter disabled and all products are returned. + * + *

Usage example: + *

{@code
+ * ProductsQuery query = ProductsQuery.builder()
+ *     .sched(true)
+ *     .nvme(true)
+ *     .build();
+ *
+ * List products = client.getProducts("amazon", "us-east-1", query);
+ * }
+ */ +public class ProductsQuery { + + private final boolean sched; + private final boolean nvme; + + private ProductsQuery(Builder builder) { + this.sched = builder.sched; + this.nvme = builder.nvme; + } + + /** + * Whether to filter results to Scheduler-supported instance families. + * + * @return {@code true} if the {@code sched=true} filter should be applied + */ + public boolean isSched() { + return sched; + } + + /** + * Whether to filter results to instance families with NVMe local storage. + * + * @return {@code true} if the {@code nvme=true} filter should be applied + */ + public boolean isNvme() { + return nvme; + } + + /** + * Creates a new Builder instance for constructing ProductsQuery objects. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for constructing ProductsQuery instances. + */ + public static class Builder { + private boolean sched; + private boolean nvme; + + /** + * Enables the Scheduler-supported filter on the products endpoint. + * + * @param sched {@code true} to apply the {@code sched=true} filter + * @return this Builder instance + */ + public Builder sched(boolean sched) { + this.sched = sched; + return this; + } + + /** + * Enables the NVMe local-storage filter on the products endpoint. + * + * @param nvme {@code true} to apply the {@code nvme=true} filter + * @return this Builder instance + */ + public Builder nvme(boolean nvme) { + this.nvme = nvme; + return this; + } + + /** + * Builds and returns a new ProductsQuery instance. + * + * @return a new ProductsQuery instance + */ + public ProductsQuery build() { + return new ProductsQuery(this); + } + } +} diff --git a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/client/CloudInfoClient.java b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/client/CloudInfoClient.java index a80e17b6..2e230780 100644 --- a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/client/CloudInfoClient.java +++ b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/client/CloudInfoClient.java @@ -27,6 +27,7 @@ import io.seqera.cloudinfo.api.CloudProduct; import io.seqera.cloudinfo.api.CloudRegion; import io.seqera.cloudinfo.api.CloudResponse; +import io.seqera.cloudinfo.api.ProductsQuery; import io.seqera.http.HxClient; import io.seqera.serde.jackson.JacksonEncodingStrategy; import org.slf4j.Logger; @@ -151,12 +152,31 @@ public List getRegionIds(String provider) { * @throws CloudInfoException if the request fails */ public List getProducts(String provider, String region) { + return getProducts(provider, region, null); + } + + /** + * Gets the list of compute products for a cloud provider and region, applying + * the optional filters in {@code query}. + * + *

When {@code query} is {@code null} or has no flags set, the request is + * equivalent to {@link #getProducts(String, String)} and all products are + * returned. Unknown filters for a provider are ignored server-side. + * + * @param provider the cloud provider identifier (e.g., "amazon", "google", "azure") + * @param region the region identifier (e.g., "us-east-1", "europe-west1") + * @param query optional filters to apply to the request; may be {@code null} + * @return list of compute products with pricing + * @throws CloudInfoException if the request fails + */ + public List getProducts(String provider, String region, ProductsQuery query) { String path = String.format("/api/v1/providers/%s/services/compute/regions/%s/products", provider, region); - log.trace("CloudInfo products: {}", path); + String url = endpoint + path + buildQueryString(query); + log.trace("CloudInfo products: {}", url); try { HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(endpoint + path)) + .uri(URI.create(url)) .GET() .timeout(Duration.ofSeconds(60)) .build(); @@ -179,6 +199,20 @@ public List getProducts(String provider, String region) { } } + private static String buildQueryString(ProductsQuery query) { + if (query == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + if (query.isSched()) { + sb.append(sb.length() == 0 ? '?' : '&').append("sched=true"); + } + if (query.isNvme()) { + sb.append(sb.length() == 0 ? '?' : '&').append("nvme=true"); + } + return sb.toString(); + } + /** * Gets the API endpoint URL. * diff --git a/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/client/CloudInfoClientTest.groovy b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/client/CloudInfoClientTest.groovy index 13df38e5..2b9bf918 100644 --- a/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/client/CloudInfoClientTest.groovy +++ b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/client/CloudInfoClientTest.groovy @@ -17,6 +17,7 @@ package io.seqera.cloudinfo.client +import io.seqera.cloudinfo.api.ProductsQuery import spock.lang.Specification import spock.lang.Shared @@ -57,4 +58,55 @@ class CloudInfoClientTest extends Specification { products.every { it.type != null } products.any { it.onDemandPrice > 0 } } + + def 'should fetch products with null query equal to no query'() { + when: + def a = client.getProducts('amazon', 'us-east-1') + def b = client.getProducts('amazon', 'us-east-1', null) + + then: + a.size() == b.size() + } + + def 'should filter products with sched=true to a subset'() { + given: + def query = ProductsQuery.builder().sched(true).build() + + when: + def all = client.getProducts('amazon', 'us-east-1') + def filtered = client.getProducts('amazon', 'us-east-1', query) + + then: + filtered.size() > 0 + filtered.size() <= all.size() + def allTypes = all*.type as Set + filtered.every { allTypes.contains(it.type) } + } + + def 'should filter products with nvme=true to a subset'() { + given: + def query = ProductsQuery.builder().nvme(true).build() + + when: + def all = client.getProducts('amazon', 'us-east-1') + def filtered = client.getProducts('amazon', 'us-east-1', query) + + then: + filtered.size() > 0 + filtered.size() <= all.size() + def allTypes = all*.type as Set + filtered.every { allTypes.contains(it.type) } + } + + def 'should return all products for unconfigured provider when filters set'() { + given: + def query = ProductsQuery.builder().sched(true).nvme(true).build() + + when: + def all = client.getProducts('google', 'us-central1') + def filtered = client.getProducts('google', 'us-central1', query) + + then: + filtered.size() == all.size() + } } From 23fb836d0d53111cc57e009bcc1727021e763668 Mon Sep 17 00:00:00 2001 From: ramonamela <25862624+ramonamela@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:41:23 +0200 Subject: [PATCH 2/3] [release] lib-cloudinfo@1.1.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib-cloudinfo/VERSION | 2 +- lib-cloudinfo/changelog.txt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib-cloudinfo/VERSION b/lib-cloudinfo/VERSION index 3eefcb9d..9084fa2f 100644 --- a/lib-cloudinfo/VERSION +++ b/lib-cloudinfo/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/lib-cloudinfo/changelog.txt b/lib-cloudinfo/changelog.txt index cc4ba00d..d8d28dbe 100644 --- a/lib-cloudinfo/changelog.txt +++ b/lib-cloudinfo/changelog.txt @@ -1,5 +1,9 @@ # lib-cloudinfo changelog +1.1.0 - 21 Apr 2026 +- Add ProductsQuery options object with sched and nvme filters +- Add getProducts(provider, region, ProductsQuery) overload on CloudInfoClient to expose the sched/nvme query parameters introduced in seqeralabs/cloudinfo#48 + 1.0.0 - 22 Jan 2026 - Initial release of lib-cloudinfo module - Add CloudInfoClient for fetching cloud provider data from Cloudinfo API From f27627a740274cd2a201212dbb5e0b44416f6e26 Mon Sep 17 00:00:00 2001 From: ramonamela <25862624+ramonamela@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:20:35 +0200 Subject: [PATCH 3/3] feat(lib-cloudinfo): add features field to CloudProduct for per-product capability tokens CloudProduct gains a List features field carrying the per-product capability tokens advertised by the CloudInfo backend (SCHED, NVME, GPU, family-type, etc.). Consumers map these strings onto a domain-specific enum (e.g. the platform's Feature enum on InstanceType). Null preserves backward compatibility for older backend versions that do not populate the field; an empty list explicitly means "no features advertised". Folded into the unreleased 1.1.0 entry of changelog.txt (no version bump). README's CloudProduct row reflects the new field. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib-cloudinfo/README.md | 2 +- lib-cloudinfo/changelog.txt | 3 +- .../io/seqera/cloudinfo/api/CloudProduct.java | 29 ++++++- .../cloudinfo/api/CloudProductTest.groovy | 87 +++++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/CloudProductTest.groovy diff --git a/lib-cloudinfo/README.md b/lib-cloudinfo/README.md index b33dd44f..391b9da2 100644 --- a/lib-cloudinfo/README.md +++ b/lib-cloudinfo/README.md @@ -78,7 +78,7 @@ CloudInfoClient client = CloudInfoClient.builder() | Class | Description | |-------|-------------| | `CloudRegion` | Region identifier and name | -| `CloudProduct` | Compute instance type with CPU, memory, GPU, and pricing | +| `CloudProduct` | Compute instance type with CPU, memory, GPU, pricing, and per-product capability features (`SCHED`, `NVME`, `GPU`, family-type, etc.) | | `CloudPrice` | Spot price for a specific zone | | `CloudResponse` | API response wrapper containing products | | `ProductAttributes` | Additional product attributes | diff --git a/lib-cloudinfo/changelog.txt b/lib-cloudinfo/changelog.txt index d8d28dbe..605a8e87 100644 --- a/lib-cloudinfo/changelog.txt +++ b/lib-cloudinfo/changelog.txt @@ -1,8 +1,9 @@ # lib-cloudinfo changelog -1.1.0 - 21 Apr 2026 +1.1.0 - unreleased - Add ProductsQuery options object with sched and nvme filters - Add getProducts(provider, region, ProductsQuery) overload on CloudInfoClient to expose the sched/nvme query parameters introduced in seqeralabs/cloudinfo#48 +- Add CloudProduct.features (List) carrying the per-product capability tokens advertised by the CloudInfo backend (e.g. SCHED, NVME, GPU, family-type). Consumers map these strings onto a domain-specific enum; null preserves backward compatibility for older backend versions that do not populate the field. 1.0.0 - 22 Jan 2026 - Initial release of lib-cloudinfo module diff --git a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/CloudProduct.java b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/CloudProduct.java index 60b68bf6..098acb15 100644 --- a/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/CloudProduct.java +++ b/lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/CloudProduct.java @@ -39,6 +39,19 @@ public class CloudProduct { private List zones; private List spotPrice; private ProductAttributes attributes; + /** + * Capability features advertised for this instance type by the CloudInfo backend + * (e.g. {@code "SCHED"}, {@code "NVME"}, {@code "GPU"}, plus a family-type token). + * + *

Consumers typically map these strings onto a domain-specific enum (such as + * the platform's {@code Feature} enum on {@code InstanceType}). Unrecognised + * tokens are dropped silently by consumers. + * + *

{@code null} means the data source did not populate features (the field is + * legacy / not yet returned by the backend version queried). An empty list + * explicitly means "no features advertised". + */ + private List features; public CloudProduct() { } @@ -139,6 +152,14 @@ public void setAttributes(ProductAttributes attributes) { this.attributes = attributes; } + public List getFeatures() { + return features; + } + + public void setFeatures(List features) { + this.features = features; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -155,13 +176,14 @@ public boolean equals(Object o) { Objects.equals(onDemandPrice, that.onDemandPrice) && Objects.equals(zones, that.zones) && Objects.equals(spotPrice, that.spotPrice) && - Objects.equals(attributes, that.attributes); + Objects.equals(attributes, that.attributes) && + Objects.equals(features, that.features); } @Override public int hashCode() { return Objects.hash(type, category, cpusPerVm, memPerVm, gpusPerVm, currentGen, - ntwPerf, ntwPerfCategory, onDemandPrice, zones, spotPrice, attributes); + ntwPerf, ntwPerfCategory, onDemandPrice, zones, spotPrice, attributes, features); } @Override @@ -177,6 +199,7 @@ public String toString() { ", onDemandPrice=" + onDemandPrice + ", zones=" + zones + ", spotPrice=" + spotPrice + - ", attributes=" + attributes + "]"; + ", attributes=" + attributes + + ", features=" + features + "]"; } } diff --git a/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/CloudProductTest.groovy b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/CloudProductTest.groovy new file mode 100644 index 00000000..173e44c3 --- /dev/null +++ b/lib-cloudinfo/src/test/groovy/io/seqera/cloudinfo/api/CloudProductTest.groovy @@ -0,0 +1,87 @@ +/* + * Copyright 2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.cloudinfo.api + +import io.seqera.serde.jackson.JacksonEncodingStrategy +import spock.lang.Specification + +class CloudProductTest extends Specification { + + private static final JacksonEncodingStrategy ENCODER = + new JacksonEncodingStrategy() {} + + def 'features field round-trips through Jackson serialisation'() { + given: + def product = new CloudProduct() + product.type = 'm5d.large' + product.features = ['SCHED', 'NVME', 'family-type:general-purpose'] + + when: + def json = ENCODER.encode(product) + def decoded = ENCODER.decode(json) + + then: + decoded.type == 'm5d.large' + decoded.features == ['SCHED', 'NVME', 'family-type:general-purpose'] + } + + def 'features field defaults to null on a freshly-constructed product'() { + when: + def product = new CloudProduct() + + then: + product.features == null + } + + def 'features field accepts an empty list distinct from null'() { + given: + def product = new CloudProduct() + + when: + product.features = [] + + then: + product.features != null + product.features.isEmpty() + } + + def 'equals and hashCode include features'() { + given: + def a = new CloudProduct(type: 'm5.large', features: ['SCHED']) + def b = new CloudProduct(type: 'm5.large', features: ['SCHED']) + def c = new CloudProduct(type: 'm5.large', features: ['SCHED', 'NVME']) + + expect: + a == b + a.hashCode() == b.hashCode() + a != c + } + + def 'features absent in the wire format deserialises to null'() { + given: 'a JSON document from a backend that does not yet emit features' + def json = '{"type":"m5.large","cpusPerVm":2}' + + when: + def decoded = ENCODER.decode(json) + + then: + decoded.type == 'm5.large' + decoded.cpusPerVm == 2 + decoded.features == null + } +}