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
21 changes: 19 additions & 2 deletions lib-cloudinfo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
```

Expand Down Expand Up @@ -39,6 +39,22 @@ List<String> regionIds = client.getRegionIds("amazon");
List<CloudProduct> 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<CloudProduct> products = client.getProducts("amazon", "us-east-1", query);
```

### Custom Configuration

```java
Expand All @@ -62,10 +78,11 @@ 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 |
| `ProductsQuery` | Optional filters (`sched`, `nvme`) for the products endpoint |

## Dependencies

Expand Down
2 changes: 1 addition & 1 deletion lib-cloudinfo/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0
1.1.0
5 changes: 5 additions & 0 deletions lib-cloudinfo/changelog.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# lib-cloudinfo changelog

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<String>) 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
- Add CloudInfoClient for fetching cloud provider data from Cloudinfo API
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ public class CloudProduct {
private List<String> zones;
private List<CloudPrice> 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).
*
* <p>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.
*
* <p>{@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<String> features;

public CloudProduct() {
}
Expand Down Expand Up @@ -139,6 +152,14 @@ public void setAttributes(ProductAttributes attributes) {
this.attributes = attributes;
}

public List<String> getFeatures() {
return features;
}

public void setFeatures(List<String> features) {
this.features = features;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand All @@ -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
Expand All @@ -177,6 +199,7 @@ public String toString() {
", onDemandPrice=" + onDemandPrice +
", zones=" + zones +
", spotPrice=" + spotPrice +
", attributes=" + attributes + "]";
", attributes=" + attributes +
", features=" + features + "]";
}
}
111 changes: 111 additions & 0 deletions lib-cloudinfo/src/main/java/io/seqera/cloudinfo/api/ProductsQuery.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Each flag is applied server-side; unset flags (the default) leave the
* corresponding filter disabled and all products are returned.
*
* <p>Usage example:
* <pre>{@code
* ProductsQuery query = ProductsQuery.builder()
* .sched(true)
* .nvme(true)
* .build();
*
* List<CloudProduct> products = client.getProducts("amazon", "us-east-1", query);
* }</pre>
*/
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -151,12 +152,31 @@ public List<String> getRegionIds(String provider) {
* @throws CloudInfoException if the request fails
*/
public List<CloudProduct> 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}.
*
* <p>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<CloudProduct> 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();
Expand All @@ -179,6 +199,20 @@ public List<CloudProduct> 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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CloudProduct> ENCODER =
new JacksonEncodingStrategy<CloudProduct>() {}

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
}
}
Loading
Loading