Skip to content
3 changes: 3 additions & 0 deletions apis/cloudapi-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ pub trait CloudApi {
async fn list_packages(
rqctx: RequestContext<Self::Context>,
path: Path<AccountPath>,
query: Query<ListPackagesQuery>,
) -> Result<HttpResponseOk<Vec<Package>>, HttpError>;

/// Head packages
Expand All @@ -810,6 +811,7 @@ pub trait CloudApi {
async fn head_packages(
rqctx: RequestContext<Self::Context>,
path: Path<AccountPath>,
query: Query<ListPackagesQuery>,
) -> Result<Response<Body>, HttpError>;

/// Get package
Expand Down Expand Up @@ -1971,6 +1973,7 @@ pub trait CloudApi {
async fn list_volumes(
rqctx: RequestContext<Self::Context>,
path: Path<AccountPath>,
query: Query<ListVolumesQuery>,
) -> Result<HttpResponseOk<Vec<Volume>>, HttpError>;

/// Create volume
Expand Down
35 changes: 35 additions & 0 deletions apis/cloudapi-api/src/types/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ pub struct Package {
pub role_tag: Option<RoleTags>,
}

/// Query parameters for listing packages
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListPackagesQuery {
/// Filter by package name
#[serde(default)]
pub name: Option<String>,
/// Filter by memory (MiB)
#[serde(default)]
pub memory: Option<u64>,
/// Filter by disk (MiB)
#[serde(default)]
pub disk: Option<u64>,
/// Filter by swap (MiB)
#[serde(default)]
pub swap: Option<u64>,
/// Filter by max lightweight processes
#[serde(default)]
pub lwps: Option<u32>,
/// Filter by virtual CPUs
#[serde(default)]
pub vcpus: Option<u32>,
/// Filter by version
#[serde(default)]
pub version: Option<String>,
/// Filter by group
#[serde(default)]
pub group: Option<String>,
/// Filter by flexible disk flag
#[serde(default)]
pub flexible_disk: Option<bool>,
/// Filter by brand
#[serde(default)]
pub brand: Option<String>,
}

/// Datacenter map: name -> URL
///
/// The CloudAPI returns datacenters as a map where keys are datacenter names
Expand Down
20 changes: 20 additions & 0 deletions apis/cloudapi-api/src/types/volume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ pub struct VolumeActionQuery {
pub action: Option<VolumeAction>,
}

/// Query parameters for listing volumes
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListVolumesQuery {
/// Filter by volume name
#[serde(default)]
pub name: Option<String>,
/// Filter by state
#[serde(default)]
pub state: Option<String>,
/// Filter by size (MiB)
#[serde(default)]
pub size: Option<u64>,
/// Filter by volume type
#[serde(default, rename = "type")]
pub volume_type: Option<String>,
/// JSON-encoded predicate expression
#[serde(default)]
pub predicate: Option<String>,
}

/// Request to update volume
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct UpdateVolumeRequest {
Expand Down
26 changes: 25 additions & 1 deletion apis/cloudapi-api/tests/serde_volume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

mod common;

use cloudapi_api::types::{CreateVolumeRequest, Volume, VolumeState, VolumeType};
use cloudapi_api::types::{CreateVolumeRequest, ListVolumesQuery, Volume, VolumeState, VolumeType};
use uuid::Uuid;

#[test]
Expand Down Expand Up @@ -202,3 +202,27 @@ fn test_create_volume_request_with_size() {
let req: CreateVolumeRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.size, Some(10240));
}

// --- ListVolumesQuery tests ---

/// The `volume_type` field uses `#[serde(rename = "type")]` because `type` is a
/// Rust keyword. This rename is load-bearing: if someone removes it or changes
/// the Rust field name without updating the rename, Dropshot will silently stop
/// parsing the `?type=` query parameter. `make openapi-check` would also catch
/// this via the generated spec, but this test makes the failure immediate and
/// obvious at the unit-test level.
#[test]
fn test_list_volumes_query_type_rename() {
// Must use "type" (the wire name), not "volume_type"
let json = r#"{"type": "tritonnfs"}"#;
let query: ListVolumesQuery = serde_json::from_str(json).unwrap();
assert_eq!(query.volume_type.as_deref(), Some("tritonnfs"));

// "volume_type" should NOT work — it's not the wire name
let json = r#"{"volume_type": "tritonnfs"}"#;
let query: ListVolumesQuery = serde_json::from_str(json).unwrap();
assert!(
query.volume_type.is_none(),
"should not accept 'volume_type' — wire format uses 'type'"
);
}
170 changes: 170 additions & 0 deletions cli/triton-cli/tests/cli_packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,176 @@ fn test_packages_json() {
common::assert_valid_uuid(first_id, "Package id");
}

/// Test `triton packages -j --name=X` filters by name
#[test]
#[ignore = "requires API access - run with make triton-test-api"]
fn test_packages_filter_by_name() {
common::config::require_integration_config();

// List all packages to get a known name.
let all_output = triton_with_profile()
.args(["packages", "-j"])
.output()
.expect("Failed to list packages");

let stdout = String::from_utf8_lossy(&all_output.stdout);
let all_packages: Vec<Value> = common::json_stream_parse(&stdout);
if all_packages.is_empty() {
eprintln!("Skipping: no packages available");
return;
}

let target_name = all_packages[0]["name"]
.as_str()
.expect("Package should have name");

// Filter by that name.
let filtered_output = triton_with_profile()
.args(["packages", "-j", "--name", target_name])
.output()
.expect("Failed to list packages filtered");

let filtered_stdout = String::from_utf8_lossy(&filtered_output.stdout);
assert!(
filtered_output.status.success(),
"filtered list should succeed"
);

let filtered: Vec<Value> = common::json_stream_parse(&filtered_stdout);
assert!(
!filtered.is_empty(),
"expected at least one package with name {target_name}"
);
for pkg in &filtered {
assert_eq!(
pkg["name"].as_str(),
Some(target_name),
"all results should match the filter name"
);
}
}

/// Test `triton packages -j --memory=X` filters by memory
#[test]
#[ignore = "requires API access - run with make triton-test-api"]
fn test_packages_filter_by_memory() {
common::config::require_integration_config();

// List all packages to get a known memory value.
let all_output = triton_with_profile()
.args(["packages", "-j"])
.output()
.expect("Failed to list packages");

let stdout = String::from_utf8_lossy(&all_output.stdout);
let all_packages: Vec<Value> = common::json_stream_parse(&stdout);
if all_packages.is_empty() {
eprintln!("Skipping: no packages available");
return;
}

let target_memory = all_packages[0]["memory"]
.as_u64()
.expect("Package should have memory");

// Filter by that memory.
let filtered_output = triton_with_profile()
.args(["packages", "-j", "--memory", &target_memory.to_string()])
.output()
.expect("Failed to list packages filtered");

let filtered_stdout = String::from_utf8_lossy(&filtered_output.stdout);
assert!(
filtered_output.status.success(),
"filtered list should succeed"
);

let filtered: Vec<Value> = common::json_stream_parse(&filtered_stdout);
assert!(
!filtered.is_empty(),
"expected at least one package with memory {target_memory}"
);
for pkg in &filtered {
assert_eq!(
pkg["memory"].as_u64(),
Some(target_memory),
"all results should match the filter memory"
);
}
}

/// Test `triton packages -j name=X` positional filter
#[test]
#[ignore = "requires API access - run with make triton-test-api"]
fn test_packages_positional_filter() {
common::config::require_integration_config();

// List all packages to get a known name.
let all_output = triton_with_profile()
.args(["packages", "-j"])
.output()
.expect("Failed to list packages");

let stdout = String::from_utf8_lossy(&all_output.stdout);
let all_packages: Vec<Value> = common::json_stream_parse(&stdout);
if all_packages.is_empty() {
eprintln!("Skipping: no packages available");
return;
}

let target_name = all_packages[0]["name"]
.as_str()
.expect("Package should have name");

// Use positional key=value filter.
let filter_arg = format!("name={target_name}");
let filtered_output = triton_with_profile()
.args(["packages", "-j", &filter_arg])
.output()
.expect("Failed to list packages with positional filter");

let filtered_stdout = String::from_utf8_lossy(&filtered_output.stdout);
assert!(
filtered_output.status.success(),
"positional filter list should succeed"
);

let filtered: Vec<Value> = common::json_stream_parse(&filtered_stdout);
assert!(
!filtered.is_empty(),
"expected at least one package with name {target_name}"
);
for pkg in &filtered {
assert_eq!(
pkg["name"].as_str(),
Some(target_name),
"all results should match the positional filter name"
);
}
}

/// Test `triton packages -j --name=nonexistent` returns empty
#[test]
#[ignore = "requires API access - run with make triton-test-api"]
fn test_packages_filter_no_match() {
common::config::require_integration_config();

let output = triton_with_profile()
.args(["packages", "-j", "--name", "nonexistent-package-zzz"])
.output()
.expect("Failed to list packages filtered");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "filtered list should succeed");

let filtered: Vec<Value> = common::json_stream_parse(&stdout);
assert!(
filtered.is_empty(),
"expected empty result for bogus filter, got {} packages",
filtered.len()
);
}

/// Test `triton package get ID` returns package details
#[test]
#[ignore = "requires API access - run with make triton-test-api"]
Expand Down
Loading
Loading