Skip to content

Commit 79b74ec

Browse files
committed
feat: Implement Volumes API
1 parent fc7e88e commit 79b74ec

5 files changed

Lines changed: 352 additions & 0 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ obelisk client execution submit -f activity-flyio:fly-http/machines@1.0.0-beta.l
4747
\"$FLY_APP_NAME\"
4848
```
4949

50+
List volumes:
51+
```sh
52+
obelisk client execution submit -f activity-flyio:fly-http/volumes@1.0.0-beta.list -- \
53+
\"$FLY_APP_NAME\"
54+
```
55+
56+
Create a volume:
57+
```sh
58+
VOLUME_ID=$(obelisk client execution submit -f --json activity-flyio:fly-http/volumes@1.0.0-beta.create -- \
59+
\"$FLY_APP_NAME\" '{
60+
"name": "my_app_vol",
61+
"region": "ams",
62+
"size-gb": 1
63+
}' | jq -r '.[-1].ok.ok.id')
64+
```
65+
66+
Destroy the volume:
67+
```sh
68+
obelisk client execution submit -f activity-flyio:fly-http/volumes@1.0.0-beta.delete -- \
69+
\"$FLY_APP_NAME\" \"$VOLUME_ID\"
70+
```
71+
5072
Launch a VM:
5173
```sh
5274
MACHINE_ID=$(obelisk client execution submit -f --json activity-flyio:fly-http/machines@1.0.0-beta.create -- \

fly-http/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod app;
22
mod machine;
33
mod secret;
4+
mod volume;
45

56
use anyhow::Context;
67
use wit_bindgen::generate;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: fly-http/src/volume.rs
3+
expression: volume
4+
---
5+
VolumeSer {
6+
id: "vol_vjeylkgg6gll7j94",
7+
name: "my_app_vol",
8+
state: "created",
9+
region: MachineRegion::Ams,
10+
size_gb: 1,
11+
encrypted: true,
12+
attached_machine_id: None,
13+
host_status: "ok",
14+
created_at: "2025-09-13T09:27:18.803Z",
15+
blocks: 0,
16+
block_size: 0,
17+
blocks_free: 0,
18+
blocks_avail: 0,
19+
bytes_used: 0,
20+
bytes_total: 0,
21+
}

fly-http/src/volume.rs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
use crate::exports::activity_flyio::fly_http::volumes::{Volume, VolumeCreateRequest};
2+
use crate::machine::ser::ToLowerWrapper;
3+
use crate::{API_BASE_URL, Component, request_with_api_token};
4+
use anyhow::{Context, anyhow, bail};
5+
use ser::{VolumeCreateRequestSer, VolumeSer};
6+
use wstd::http::request::JsonRequest;
7+
use wstd::http::{Client, Method};
8+
use wstd::runtime::block_on;
9+
10+
// These structs are internal implementation details. They are designed to serialize
11+
// into the exact JSON format expected by the Fly.io Volumes API.
12+
pub(crate) mod ser {
13+
use crate::exports::activity_flyio::fly_http::{machines::MachineRegion, volumes::Volume};
14+
use crate::machine::ser::ToLowerWrapper;
15+
use serde::{Deserialize, Serialize};
16+
17+
#[derive(Serialize, Debug)]
18+
pub(crate) struct VolumeCreateRequestSer {
19+
pub(crate) name: String,
20+
pub(crate) size_gb: u32,
21+
pub(crate) region: ToLowerWrapper<MachineRegion>,
22+
#[serde(rename = "require_unique_zone")]
23+
pub(crate) require_unique_zone: Option<bool>,
24+
}
25+
26+
#[derive(Deserialize, Debug)]
27+
pub(crate) struct VolumeSer {
28+
pub(crate) id: String,
29+
pub(crate) name: String,
30+
pub(crate) state: String,
31+
pub(crate) region: ToLowerWrapper<MachineRegion>,
32+
pub(crate) size_gb: u32,
33+
pub(crate) encrypted: bool,
34+
pub(crate) attached_machine_id: Option<String>,
35+
pub(crate) host_status: String,
36+
pub(crate) created_at: String,
37+
pub(crate) blocks: u32,
38+
pub(crate) block_size: u32,
39+
pub(crate) blocks_free: u32,
40+
pub(crate) blocks_avail: u32,
41+
pub(crate) bytes_used: u32,
42+
pub(crate) bytes_total: u32,
43+
}
44+
45+
impl From<VolumeSer> for Volume {
46+
fn from(value: VolumeSer) -> Volume {
47+
Volume {
48+
id: value.id,
49+
name: value.name,
50+
state: value.state,
51+
region: value.region.0,
52+
size_gb: value.size_gb,
53+
encrypted: value.encrypted,
54+
attached_machine_id: value.attached_machine_id,
55+
host_status: value.host_status,
56+
created_at: value.created_at,
57+
blocks: value.blocks,
58+
block_size: value.block_size,
59+
blocks_free: value.blocks_free,
60+
blocks_avail: value.blocks_avail,
61+
bytes_used: value.bytes_used,
62+
bytes_total: value.bytes_total,
63+
}
64+
}
65+
}
66+
}
67+
68+
async fn list(app_name: String) -> Result<Vec<Volume>, anyhow::Error> {
69+
let url = format!("{API_BASE_URL}/apps/{app_name}/volumes");
70+
let request = request_with_api_token()?
71+
.method(Method::GET)
72+
.uri(url)
73+
.body(wstd::io::empty())?;
74+
let response = Client::new().send(request).await?;
75+
76+
if response.status().is_success() {
77+
let response_body = response.into_body().bytes().await?;
78+
let response_ser: Vec<VolumeSer> =
79+
serde_json::from_slice(&response_body).inspect_err(|_| {
80+
eprintln!(
81+
"cannot deserialize: {}",
82+
String::from_utf8_lossy(&response_body)
83+
)
84+
})?;
85+
Ok(response_ser.into_iter().map(Volume::from).collect())
86+
} else {
87+
let error_status = response.status();
88+
let error_body = response.into_body().bytes().await?;
89+
Err(anyhow!(
90+
"failed with status {error_status}: {}",
91+
String::from_utf8_lossy(&error_body)
92+
))
93+
}
94+
}
95+
96+
async fn create(app_name: String, request: VolumeCreateRequest) -> Result<Volume, anyhow::Error> {
97+
let fly_request = VolumeCreateRequestSer {
98+
name: request.name,
99+
size_gb: request.size_gb,
100+
region: ToLowerWrapper(request.region),
101+
require_unique_zone: request.require_unique_zone,
102+
};
103+
let url = format!("{API_BASE_URL}/apps/{app_name}/volumes");
104+
let http_request = request_with_api_token()?
105+
.method(Method::POST)
106+
.uri(url)
107+
.json(&fly_request)?;
108+
109+
let response = Client::new().send(http_request).await?;
110+
111+
if response.status().is_success() {
112+
let response_body = response.into_body().bytes().await?;
113+
let volume_ser: VolumeSer = serde_json::from_slice(&response_body).with_context(|| {
114+
format!(
115+
"Deserialization of response failed: `{}`",
116+
String::from_utf8_lossy(&response_body)
117+
)
118+
})?;
119+
Ok(Volume::from(volume_ser))
120+
} else {
121+
let error_status = response.status();
122+
let error_body = response.into_body().bytes().await?;
123+
bail!("{error_status} - {}", String::from_utf8_lossy(&error_body))
124+
}
125+
}
126+
127+
async fn get(app_name: String, volume_id: String) -> Result<Volume, anyhow::Error> {
128+
let url = format!("{API_BASE_URL}/apps/{app_name}/volumes/{volume_id}");
129+
let request = request_with_api_token()?
130+
.method(Method::GET)
131+
.uri(url)
132+
.body(wstd::io::empty())?;
133+
let response = Client::new().send(request).await?;
134+
135+
if response.status().is_success() {
136+
let response_body = response.into_body().bytes().await?;
137+
let volume_ser: VolumeSer = serde_json::from_slice(&response_body).inspect_err(|_| {
138+
eprintln!(
139+
"cannot deserialize: {}",
140+
String::from_utf8_lossy(&response_body)
141+
)
142+
})?;
143+
Ok(Volume::from(volume_ser))
144+
} else {
145+
let error_status = response.status();
146+
let error_body = response.into_body().bytes().await?;
147+
Err(anyhow!(
148+
"failed with status {error_status}: {}",
149+
String::from_utf8_lossy(&error_body)
150+
))
151+
}
152+
}
153+
154+
async fn delete(app_name: String, volume_id: String) -> Result<(), anyhow::Error> {
155+
let url = format!("{API_BASE_URL}/apps/{app_name}/volumes/{volume_id}");
156+
let request = request_with_api_token()?
157+
.method(Method::DELETE)
158+
.uri(url)
159+
.body(wstd::io::empty())?;
160+
161+
let response = Client::new().send(request).await?;
162+
163+
if response.status().is_success() {
164+
Ok(())
165+
} else {
166+
let error_status = response.status();
167+
let error_body = response.into_body().bytes().await?;
168+
Err(anyhow!(
169+
"failed with status {error_status}: {}",
170+
String::from_utf8_lossy(&error_body)
171+
))
172+
}
173+
}
174+
175+
async fn extend(
176+
app_name: String,
177+
volume_id: String,
178+
new_size_gb: u32,
179+
) -> Result<(), anyhow::Error> {
180+
let url = format!("{API_BASE_URL}/apps/{app_name}/volumes/{volume_id}/extend");
181+
let body = serde_json::json!({
182+
"size_gb": new_size_gb,
183+
});
184+
let request = request_with_api_token()?
185+
.method(Method::PUT)
186+
.uri(url)
187+
.json(&body)?;
188+
189+
let response = Client::new().send(request).await?;
190+
191+
if response.status().is_success() {
192+
Ok(())
193+
} else {
194+
let error_status = response.status();
195+
let error_body = response.into_body().bytes().await?;
196+
Err(anyhow!(
197+
"failed with status {error_status}: {}",
198+
String::from_utf8_lossy(&error_body)
199+
))
200+
}
201+
}
202+
203+
// Implementation of the volumes interface for the component.
204+
impl crate::exports::activity_flyio::fly_http::volumes::Guest for Component {
205+
fn list(app_name: String) -> Result<Vec<Volume>, String> {
206+
block_on(list(app_name)).map_err(|err| err.to_string())
207+
}
208+
209+
fn create(app_name: String, request: VolumeCreateRequest) -> Result<Volume, String> {
210+
block_on(create(app_name, request)).map_err(|err| err.to_string())
211+
}
212+
213+
fn get(app_name: String, volume_id: String) -> Result<Volume, String> {
214+
block_on(get(app_name, volume_id)).map_err(|err| err.to_string())
215+
}
216+
217+
fn delete(app_name: String, volume_id: String) -> Result<(), String> {
218+
block_on(delete(app_name, volume_id)).map_err(|err| err.to_string())
219+
}
220+
221+
fn extend(app_name: String, volume_id: String, new_size_gb: u32) -> Result<(), String> {
222+
block_on(extend(app_name, volume_id, new_size_gb)).map_err(|err| err.to_string())
223+
}
224+
}
225+
226+
#[cfg(test)]
227+
mod tests {
228+
use super::ser::VolumeSer;
229+
use insta::assert_debug_snapshot;
230+
231+
#[test]
232+
fn volume_deserialization() {
233+
let json = r#"
234+
{
235+
"id": "vol_vjeylkgg6gll7j94",
236+
"name": "my_app_vol",
237+
"state": "created",
238+
"size_gb": 1,
239+
"region": "ams",
240+
"zone": "119a",
241+
"encrypted": true,
242+
"attached_machine_id": null,
243+
"attached_alloc_id": null,
244+
"created_at": "2025-09-13T09:27:18.803Z",
245+
"blocks": 0,
246+
"block_size": 0,
247+
"blocks_free": 0,
248+
"blocks_avail": 0,
249+
"bytes_used": 0,
250+
"bytes_total": 0,
251+
"fstype": "ext4",
252+
"snapshot_retention": 5,
253+
"auto_backup_enabled": true,
254+
"host_status": "ok",
255+
"host_dedication_key": ""
256+
}
257+
"#;
258+
let volume: VolumeSer = serde_json::from_str(json).unwrap();
259+
assert_debug_snapshot!(volume)
260+
}
261+
}

fly-http/wit/activity-flyio_fly-http@1.0.0-beta/fly.wit

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,55 @@ interface secrets {
215215

216216
}
217217

218+
/// [Volumes API](https://docs.machines.dev/#tag/volumes)
219+
interface volumes {
220+
use machines.{machine-region};
221+
222+
record volume {
223+
id: string,
224+
name: string,
225+
state: string,
226+
region: machine-region,
227+
size-gb: u32,
228+
encrypted: bool,
229+
attached-machine-id: option<string>,
230+
host-status: string,
231+
created-at: string,
232+
blocks: u32,
233+
block-size: u32,
234+
blocks-free: u32,
235+
blocks-avail: u32,
236+
bytes-used: u32,
237+
bytes-total: u32,
238+
}
239+
240+
record volume-create-request {
241+
name: string,
242+
size-gb: u32,
243+
region: machine-region,
244+
require-unique-zone: option<bool>,
245+
}
246+
247+
/// List all the volumes in an app.
248+
%list: func(app-name: string) -> result<list<volume>, string>;
249+
250+
/// Create a volume.
251+
create: func(app-name: string, request: volume-create-request) -> result<volume, string>;
252+
253+
/// Get a specific volume.
254+
get: func(app-name: string, volume-id: string) -> result<volume, string>;
255+
256+
/// Extend a volume.
257+
extend: func(app-name: string, volume-id: string, new-size-gb: u32) -> result<_, string>;
258+
259+
/// Delete a volume permanently.
260+
delete: func(app-name: string, volume-id: string) -> result<_, string>;
261+
262+
}
263+
218264
world exports {
219265
export apps;
220266
export machines;
221267
export secrets;
268+
export volumes;
222269
}

0 commit comments

Comments
 (0)