Skip to content

Commit 8a0afca

Browse files
committed
feat: add Basilica API client for container provisioning
- Add basilica/ module with full REST API client (types.rs, client.rs) - Support community cloud rentals, secure cloud CPU/GPU, deployments - Add /basilica/* HTTP handlers for container lifecycle management - Auto-select cheapest CPU offering matching requested specs - Poll rental status until SSH is ready - Config: BASILICA_API_TOKEN env var to enable Basilica integration
1 parent d83cb8c commit 8a0afca

File tree

6 files changed

+955
-0
lines changed

6 files changed

+955
-0
lines changed

src/basilica/client.rs

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
use anyhow::{Context, Result};
2+
use tracing::{debug, info, warn};
3+
4+
use super::types::*;
5+
6+
const DEFAULT_API_URL: &str = "https://api.basilica.ai";
7+
const DEFAULT_TIMEOUT_SECS: u64 = 120;
8+
const POLL_INTERVAL_SECS: u64 = 5;
9+
const MAX_POLL_ATTEMPTS: u32 = 60;
10+
11+
pub struct BasilicaClient {
12+
client: reqwest::Client,
13+
base_url: String,
14+
}
15+
16+
impl BasilicaClient {
17+
pub fn new(api_token: &str) -> Result<Self> {
18+
let base_url = std::env::var("BASILICA_API_URL")
19+
.unwrap_or_else(|_| DEFAULT_API_URL.to_string());
20+
21+
let mut headers = reqwest::header::HeaderMap::new();
22+
let auth_value = format!("Bearer {}", api_token);
23+
headers.insert(
24+
reqwest::header::AUTHORIZATION,
25+
auth_value
26+
.parse()
27+
.context("Invalid API token format")?,
28+
);
29+
30+
let client = reqwest::Client::builder()
31+
.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS))
32+
.default_headers(headers)
33+
.build()
34+
.context("Failed to build Basilica HTTP client")?;
35+
36+
Ok(Self { client, base_url })
37+
}
38+
39+
// ── Health ──
40+
41+
pub async fn health(&self) -> Result<HealthResponse> {
42+
let url = format!("{}/health", self.base_url);
43+
let resp = self.client.get(&url).send().await
44+
.context("Basilica health check failed")?;
45+
self.handle_response(resp, "health").await
46+
}
47+
48+
// ── SSH keys ──
49+
50+
pub async fn register_ssh_key(&self, name: &str, public_key: &str) -> Result<SshKeyResponse> {
51+
let url = format!("{}/ssh-keys", self.base_url);
52+
let body = RegisterSshKeyRequest {
53+
name: name.to_string(),
54+
public_key: public_key.to_string(),
55+
};
56+
let resp = self.client.post(&url).json(&body).send().await
57+
.context("Failed to register SSH key")?;
58+
self.handle_response(resp, "register_ssh_key").await
59+
}
60+
61+
pub async fn get_ssh_key(&self) -> Result<Option<SshKeyResponse>> {
62+
let url = format!("{}/ssh-keys", self.base_url);
63+
let resp = self.client.get(&url).send().await
64+
.context("Failed to get SSH key")?;
65+
if resp.status() == reqwest::StatusCode::NOT_FOUND {
66+
return Ok(None);
67+
}
68+
let key: SshKeyResponse = self.handle_response(resp, "get_ssh_key").await?;
69+
Ok(Some(key))
70+
}
71+
72+
pub async fn delete_ssh_key(&self) -> Result<()> {
73+
let url = format!("{}/ssh-keys", self.base_url);
74+
let resp = self.client.delete(&url).send().await
75+
.context("Failed to delete SSH key")?;
76+
self.handle_empty_response(resp, "delete_ssh_key").await
77+
}
78+
79+
// ── Community cloud rentals (GPU) ──
80+
81+
pub async fn start_rental(&self, req: &StartRentalRequest) -> Result<RentalResponse> {
82+
let url = format!("{}/rentals", self.base_url);
83+
info!("Starting Basilica rental: image={}", req.container_image);
84+
let resp = self.client.post(&url).json(req).send().await
85+
.context("Failed to start rental")?;
86+
self.handle_response(resp, "start_rental").await
87+
}
88+
89+
pub async fn get_rental(&self, rental_id: &str) -> Result<RentalStatusResponse> {
90+
let url = format!("{}/rentals/{}", self.base_url, rental_id);
91+
let resp = self.client.get(&url).send().await
92+
.context("Failed to get rental status")?;
93+
self.handle_response(resp, "get_rental").await
94+
}
95+
96+
pub async fn stop_rental(&self, rental_id: &str) -> Result<()> {
97+
let url = format!("{}/rentals/{}", self.base_url, rental_id);
98+
info!("Stopping Basilica rental: {}", rental_id);
99+
let resp = self.client.delete(&url).send().await
100+
.context("Failed to stop rental")?;
101+
self.handle_empty_response(resp, "stop_rental").await
102+
}
103+
104+
pub async fn list_rentals(&self) -> Result<ListRentalsResponse> {
105+
let url = format!("{}/rentals", self.base_url);
106+
let resp = self.client.get(&url).send().await
107+
.context("Failed to list rentals")?;
108+
self.handle_response(resp, "list_rentals").await
109+
}
110+
111+
// ── Secure cloud CPU rentals ──
112+
113+
pub async fn list_cpu_offerings(&self) -> Result<ListCpuOfferingsResponse> {
114+
let url = format!("{}/secure-cloud/cpu-prices?available_only=true", self.base_url);
115+
let resp = self.client.get(&url).send().await
116+
.context("Failed to list CPU offerings")?;
117+
self.handle_response(resp, "list_cpu_offerings").await
118+
}
119+
120+
pub async fn start_cpu_rental(&self, offering_id: &str, ssh_key_id: &str) -> Result<SecureCloudRentalResponse> {
121+
let url = format!("{}/secure-cloud/cpu-rentals/start", self.base_url);
122+
let body = StartCpuRentalRequest {
123+
offering_id: offering_id.to_string(),
124+
ssh_public_key_id: ssh_key_id.to_string(),
125+
};
126+
info!("Starting Basilica CPU rental: offering={}", offering_id);
127+
let resp = self.client.post(&url).json(&body).send().await
128+
.context("Failed to start CPU rental")?;
129+
self.handle_response(resp, "start_cpu_rental").await
130+
}
131+
132+
pub async fn stop_cpu_rental(&self, rental_id: &str) -> Result<StopRentalResponse> {
133+
let url = format!("{}/secure-cloud/cpu-rentals/{}/stop", self.base_url, rental_id);
134+
info!("Stopping Basilica CPU rental: {}", rental_id);
135+
let resp = self.client.post(&url).json(&serde_json::json!({})).send().await
136+
.context("Failed to stop CPU rental")?;
137+
self.handle_response(resp, "stop_cpu_rental").await
138+
}
139+
140+
// ── Secure cloud GPU rentals ──
141+
142+
pub async fn list_gpu_offerings(&self) -> Result<serde_json::Value> {
143+
let url = format!("{}/secure-cloud/gpu-prices?available_only=true", self.base_url);
144+
let resp = self.client.get(&url).send().await
145+
.context("Failed to list GPU offerings")?;
146+
self.handle_response(resp, "list_gpu_offerings").await
147+
}
148+
149+
pub async fn start_gpu_rental(&self, offering_id: &str, ssh_key_id: &str) -> Result<SecureCloudRentalResponse> {
150+
let url = format!("{}/secure-cloud/rentals/start", self.base_url);
151+
let body = StartCpuRentalRequest {
152+
offering_id: offering_id.to_string(),
153+
ssh_public_key_id: ssh_key_id.to_string(),
154+
};
155+
info!("Starting Basilica GPU rental: offering={}", offering_id);
156+
let resp = self.client.post(&url).json(&body).send().await
157+
.context("Failed to start GPU rental")?;
158+
self.handle_response(resp, "start_gpu_rental").await
159+
}
160+
161+
pub async fn stop_gpu_rental(&self, rental_id: &str) -> Result<StopRentalResponse> {
162+
let url = format!("{}/secure-cloud/rentals/{}/stop", self.base_url, rental_id);
163+
info!("Stopping Basilica GPU rental: {}", rental_id);
164+
let resp = self.client.post(&url).json(&serde_json::json!({})).send().await
165+
.context("Failed to stop GPU rental")?;
166+
self.handle_response(resp, "stop_gpu_rental").await
167+
}
168+
169+
// ── Deployments ──
170+
171+
pub async fn create_deployment(&self, req: &CreateDeploymentRequest) -> Result<DeploymentResponse> {
172+
let url = format!("{}/deployments", self.base_url);
173+
info!("Creating Basilica deployment: name={}, image={}", req.instance_name, req.image);
174+
let resp = self.client.post(&url).json(req).send().await
175+
.context("Failed to create deployment")?;
176+
self.handle_response(resp, "create_deployment").await
177+
}
178+
179+
pub async fn get_deployment(&self, name: &str) -> Result<DeploymentResponse> {
180+
let url = format!("{}/deployments/{}", self.base_url, name);
181+
let resp = self.client.get(&url).send().await
182+
.context("Failed to get deployment")?;
183+
self.handle_response(resp, "get_deployment").await
184+
}
185+
186+
pub async fn delete_deployment(&self, name: &str) -> Result<DeleteDeploymentResponse> {
187+
let url = format!("{}/deployments/{}", self.base_url, name);
188+
info!("Deleting Basilica deployment: {}", name);
189+
let resp = self.client.delete(&url).send().await
190+
.context("Failed to delete deployment")?;
191+
self.handle_response(resp, "delete_deployment").await
192+
}
193+
194+
// ── Balance ──
195+
196+
pub async fn get_balance(&self) -> Result<BalanceResponse> {
197+
let url = format!("{}/billing/balance", self.base_url);
198+
let resp = self.client.get(&url).send().await
199+
.context("Failed to get balance")?;
200+
self.handle_response(resp, "get_balance").await
201+
}
202+
203+
// ── High-level: provision CPU container and wait for SSH ──
204+
205+
pub async fn provision_cpu_container(
206+
&self,
207+
ssh_key_id: &str,
208+
min_cpu: Option<u32>,
209+
min_memory_gb: Option<u32>,
210+
) -> Result<ContainerInfo> {
211+
let offerings = self.list_cpu_offerings().await?;
212+
let offering = offerings.nodes.iter()
213+
.filter(|o| {
214+
min_cpu.map_or(true, |c| o.cpu_count.unwrap_or(0) >= c)
215+
&& min_memory_gb.map_or(true, |m| o.memory_gb.unwrap_or(0) >= m)
216+
})
217+
.min_by_key(|o| o.hourly_rate_cents.unwrap_or(u32::MAX))
218+
.context("No CPU offering matches the requested specs")?;
219+
220+
info!(
221+
"Selected CPU offering: {} ({}cpu, {}GB, {}c/hr)",
222+
offering.id,
223+
offering.cpu_count.unwrap_or(0),
224+
offering.memory_gb.unwrap_or(0),
225+
offering.hourly_rate_cents.unwrap_or(0),
226+
);
227+
228+
let rental = self.start_cpu_rental(&offering.id, ssh_key_id).await?;
229+
info!("CPU rental started: {} (status: {})", rental.rental_id, rental.status);
230+
231+
self.wait_for_ssh(&rental.rental_id).await
232+
}
233+
234+
/// Poll rental status until SSH is available or timeout.
235+
async fn wait_for_ssh(&self, rental_id: &str) -> Result<ContainerInfo> {
236+
for attempt in 1..=MAX_POLL_ATTEMPTS {
237+
tokio::time::sleep(std::time::Duration::from_secs(POLL_INTERVAL_SECS)).await;
238+
239+
let status = self.get_rental(rental_id).await;
240+
match status {
241+
Ok(s) => {
242+
debug!(
243+
"Rental {} poll {}/{}: status={}",
244+
rental_id, attempt, MAX_POLL_ATTEMPTS, s.status
245+
);
246+
247+
if s.status == "running" || s.status == "active" {
248+
if let Some(ref creds) = s.ssh_credentials {
249+
if creds.host.is_some() {
250+
info!("Rental {} is ready with SSH access", rental_id);
251+
return Ok(ContainerInfo {
252+
rental_id: rental_id.to_string(),
253+
status: s.status,
254+
ssh_host: creds.host.clone(),
255+
ssh_port: creds.port,
256+
ssh_user: creds.username.clone(),
257+
ssh_command: creds.ssh_command.clone(),
258+
provider: s.node.clone(),
259+
created_at: s.created_at.clone(),
260+
});
261+
}
262+
}
263+
}
264+
265+
if s.status == "failed" || s.status == "error" || s.status == "terminated" {
266+
anyhow::bail!("Rental {} entered terminal state: {}", rental_id, s.status);
267+
}
268+
}
269+
Err(e) => {
270+
warn!("Failed to poll rental {}: {}", rental_id, e);
271+
}
272+
}
273+
}
274+
275+
anyhow::bail!(
276+
"Rental {} did not become ready within {}s",
277+
rental_id,
278+
MAX_POLL_ATTEMPTS as u64 * POLL_INTERVAL_SECS
279+
)
280+
}
281+
282+
// ── Response handling ──
283+
284+
async fn handle_response<T: serde::de::DeserializeOwned>(
285+
&self,
286+
resp: reqwest::Response,
287+
operation: &str,
288+
) -> Result<T> {
289+
let status = resp.status();
290+
if !status.is_success() {
291+
let body = resp.text().await.unwrap_or_default();
292+
anyhow::bail!(
293+
"Basilica API {} failed (HTTP {}): {}",
294+
operation,
295+
status.as_u16(),
296+
&body[..body.len().min(500)]
297+
);
298+
}
299+
resp.json::<T>().await
300+
.with_context(|| format!("Failed to parse Basilica {} response", operation))
301+
}
302+
303+
async fn handle_empty_response(
304+
&self,
305+
resp: reqwest::Response,
306+
operation: &str,
307+
) -> Result<()> {
308+
let status = resp.status();
309+
if !status.is_success() {
310+
let body = resp.text().await.unwrap_or_default();
311+
anyhow::bail!(
312+
"Basilica API {} failed (HTTP {}): {}",
313+
operation,
314+
status.as_u16(),
315+
&body[..body.len().min(500)]
316+
);
317+
}
318+
Ok(())
319+
}
320+
}
321+
322+
#[cfg(test)]
323+
mod tests {
324+
use super::*;
325+
326+
#[test]
327+
fn test_client_creation() {
328+
let client = BasilicaClient::new("test-token-123");
329+
assert!(client.is_ok());
330+
}
331+
332+
#[test]
333+
fn test_default_api_url() {
334+
std::env::remove_var("BASILICA_API_URL");
335+
let client = BasilicaClient::new("test").unwrap();
336+
assert_eq!(client.base_url, DEFAULT_API_URL);
337+
}
338+
}

src/basilica/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod client;
2+
pub mod types;

0 commit comments

Comments
 (0)