use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub struct FlyioClient {
api_token: String,
base_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioApp {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub organization: Option<FlyioOrganization>,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioOrganization {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub slug: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioMachine {
pub id: String,
pub name: String,
pub state: String,
pub region: String,
pub image_ref: Option<FlyioImageRef>,
pub config: FlyioMachineConfig,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioImageRef {
pub registry: String,
pub repository: String,
pub tag: String,
pub digest: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioMachineConfig {
pub image: String,
pub env: HashMap<String, String>,
pub services: Vec<FlyioService>,
pub checks: Option<HashMap<String, FlyioCheck>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioRegistryAuth {
pub server: String,
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioService {
pub protocol: String,
pub internal_port: u16,
pub ports: Vec<FlyioPort>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioPort {
pub port: u16,
pub handlers: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FlyioCheck {
#[serde(rename = "type")]
pub check_type: String,
pub port: u16,
pub grace_period: String,
pub interval: String,
pub method: String,
pub timeout: String,
pub tls_skip_verify: bool,
pub path: Option<String>,
}
impl FlyioClient {
pub fn new(api_token: String) -> Self {
Self {
api_token,
base_url: "https://api.machines.dev".to_string(),
}
}
pub fn api_token(&self) -> &str {
&self.api_token
}
pub async fn create_app(&self, app_name: &str, org_slug: &str) -> Result<FlyioApp> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps", self.base_url);
let payload = serde_json::json!({
"app_name": app_name,
"org_slug": org_slug,
});
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.context("Failed to create Fly.io app")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
if status.as_u16() == 422 && error_text.contains("already been taken") {
tracing::info!("Fly.io app '{}' already exists, fetching existing app", app_name);
return self.get_app(app_name).await;
}
anyhow::bail!("Failed to create Fly.io app: {} - {}", status, error_text);
}
let app: FlyioApp = response.json().await.context("Failed to parse Fly.io app response")?;
Ok(app)
}
pub async fn create_machine(
&self,
app_name: &str,
config: FlyioMachineConfig,
region: &str,
registry_auth: Option<FlyioRegistryAuth>,
) -> Result<FlyioMachine> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines", self.base_url, app_name);
let mut payload = serde_json::json!({
"config": config,
"region": region,
});
if let Some(auth) = registry_auth {
payload["config"]["image_registry_auth"] =
serde_json::to_value(auth).context("Failed to serialize registry auth")?;
}
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.context("Failed to create Fly.io machine")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to create Fly.io machine: {} - {}", status, error_text);
}
let machine: FlyioMachine =
response.json().await.context("Failed to parse Fly.io machine response")?;
Ok(machine)
}
pub async fn update_machine(
&self,
app_name: &str,
machine_id: &str,
config: FlyioMachineConfig,
registry_auth: Option<FlyioRegistryAuth>,
) -> Result<FlyioMachine> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines/{}", self.base_url, app_name, machine_id);
let mut payload = serde_json::json!({
"config": config,
});
if let Some(auth) = registry_auth {
payload["config"]["image_registry_auth"] =
serde_json::to_value(auth).context("Failed to serialize registry auth")?;
}
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.context("Failed to update Fly.io machine")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to update Fly.io machine: {} - {}", status, error_text);
}
let machine: FlyioMachine =
response.json().await.context("Failed to parse Fly.io machine response")?;
Ok(machine)
}
pub async fn get_machine(&self, app_name: &str, machine_id: &str) -> Result<FlyioMachine> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines/{}", self.base_url, app_name, machine_id);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
.context("Failed to get Fly.io machine")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to get Fly.io machine: {} - {}", status, error_text);
}
let machine: FlyioMachine =
response.json().await.context("Failed to parse Fly.io machine response")?;
Ok(machine)
}
pub async fn stop_machine(&self, app_name: &str, machine_id: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines/{}/stop", self.base_url, app_name, machine_id);
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json")
.json(&serde_json::json!({}))
.send()
.await
.context("Failed to stop Fly.io machine")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to stop Fly.io machine: {} - {}", status, error_text);
}
Ok(())
}
pub async fn start_machine(&self, app_name: &str, machine_id: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines/{}/start", self.base_url, app_name, machine_id);
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json")
.send()
.await
.context("Failed to start Fly.io machine")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to start Fly.io machine: {} - {}", status, error_text);
}
Ok(())
}
pub async fn delete_machine(&self, app_name: &str, machine_id: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines/{}", self.base_url, app_name, machine_id);
let response = client
.delete(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
.context("Failed to delete Fly.io machine")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to delete Fly.io machine: {} - {}", status, error_text);
}
Ok(())
}
pub async fn delete_app(&self, app_name: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}", self.base_url, app_name);
let response = client
.delete(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
.context("Failed to delete Fly.io app")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to delete Fly.io app: {} - {}", status, error_text);
}
Ok(())
}
pub async fn allocate_ips(&self, app_name: &str) -> Result<()> {
let client = reqwest::Client::new();
let graphql_url = "https://api.fly.io/graphql";
let ipv4_query = serde_json::json!({
"query": "mutation($input: AllocateIPAddressInput!) { allocateIpAddress(input: $input) { ipAddress { id address type } } }",
"variables": {
"input": {
"appId": app_name,
"type": "shared_v4"
}
}
});
let response = client
.post(graphql_url)
.header("Authorization", format!("Bearer {}", self.api_token))
.json(&ipv4_query)
.send()
.await
.context("Failed to allocate shared IPv4")?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to allocate shared IPv4: {}", error_text);
}
let ipv6_query = serde_json::json!({
"query": "mutation($input: AllocateIPAddressInput!) { allocateIpAddress(input: $input) { ipAddress { id address type } } }",
"variables": {
"input": {
"appId": app_name,
"type": "v6"
}
}
});
let response = client
.post(graphql_url)
.header("Authorization", format!("Bearer {}", self.api_token))
.json(&ipv6_query)
.send()
.await
.context("Failed to allocate IPv6")?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to allocate IPv6: {}", error_text);
}
Ok(())
}
pub async fn get_app(&self, app_name: &str) -> Result<FlyioApp> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}", self.base_url, app_name);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
.context("Failed to get Fly.io app")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to get Fly.io app: {} - {}", status, error_text);
}
let app: FlyioApp = response.json().await.context("Failed to parse Fly.io app response")?;
Ok(app)
}
pub async fn add_certificate(&self, app_name: &str, hostname: &str) -> Result<()> {
let client = reqwest::Client::new();
let graphql_url = "https://api.fly.io/graphql";
let query = serde_json::json!({
"query": "mutation($appId: ID!, $hostname: String!) { addCertificate(appId: $appId, hostname: $hostname) { certificate { id hostname } } }",
"variables": {
"appId": app_name,
"hostname": hostname
}
});
let response = client
.post(graphql_url)
.header("Authorization", format!("Bearer {}", self.api_token))
.json(&query)
.send()
.await
.context("Failed to add certificate")?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to add certificate for {}: {}", hostname, error_text);
}
let body: serde_json::Value =
response.json().await.context("Failed to parse certificate response")?;
if let Some(errors) = body.get("errors") {
let err_str = errors.to_string();
if !err_str.contains("already exists") {
anyhow::bail!("Failed to add certificate for {}: {}", hostname, err_str);
}
}
Ok(())
}
pub async fn delete_certificate(&self, app_name: &str, hostname: &str) -> Result<()> {
let client = reqwest::Client::new();
let graphql_url = "https://api.fly.io/graphql";
let query = serde_json::json!({
"query": "mutation($appId: ID!, $hostname: String!) { deleteCertificate(appId: $appId, hostname: $hostname) { app { name } } }",
"variables": {
"appId": app_name,
"hostname": hostname
}
});
let response = client
.post(graphql_url)
.header("Authorization", format!("Bearer {}", self.api_token))
.json(&query)
.send()
.await
.context("Failed to delete certificate")?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to delete certificate for {}: {}", hostname, error_text);
}
Ok(())
}
pub async fn list_machines(&self, app_name: &str) -> Result<Vec<FlyioMachine>> {
let client = reqwest::Client::new();
let url = format!("{}/v1/apps/{}/machines", self.base_url, app_name);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
.context("Failed to list Fly.io machines")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
anyhow::bail!("Failed to list Fly.io machines: {} - {}", status, error_text);
}
let machines: Vec<FlyioMachine> =
response.json().await.context("Failed to parse Fly.io machines response")?;
Ok(machines)
}
}