chore: full stack stability and migration fixes, plus react UI progress
This commit is contained in:
85
control_plane/src/database.rs
Normal file
85
control_plane/src/database.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BackupInfo {
|
||||
pub url: String,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RestoreResult {
|
||||
pub restored_at: DateTime<Utc>,
|
||||
pub databases: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct DatabaseManager {
|
||||
db: PgPool,
|
||||
}
|
||||
|
||||
impl DatabaseManager {
|
||||
pub fn new(db: PgPool) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Backup database to S3
|
||||
pub async fn backup(&self) -> Result<BackupInfo> {
|
||||
// Use pg_dump and upload to S3
|
||||
// This is a simplified version - actual implementation would:
|
||||
// 1. Execute pg_dump on primary node
|
||||
// 2. Compress backup
|
||||
// 3. Upload to S3 bucket
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let url = format!("s3://madbase-backups/db_backup_{}.sql.gz", timestamp);
|
||||
|
||||
sqlx::query("INSERT INTO backups (url, created_at, size_bytes) VALUES ($1, NOW(), 0)")
|
||||
.bind(&url)
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(BackupInfo {
|
||||
url,
|
||||
size_bytes: 0,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Restore database from S3 backup
|
||||
pub async fn restore(&self, _backup_url: &str) -> Result<RestoreResult> {
|
||||
// Download from S3 and restore using psql
|
||||
// Actual implementation would:
|
||||
// 1. Download backup from S3
|
||||
// 2. Decompress
|
||||
// 3. Restore using psql
|
||||
|
||||
Ok(RestoreResult {
|
||||
restored_at: Utc::now(),
|
||||
databases: vec!["madbase".to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
/// Add node to Patroni cluster
|
||||
pub async fn add_node_to_cluster(&self, ip_address: &str) -> Result<()> {
|
||||
// Update Patroni configuration to include new node
|
||||
// This would typically involve:
|
||||
// 1. SSH to existing node
|
||||
// 2. Update etcd configuration
|
||||
// 3. Restart Patroni on new node
|
||||
|
||||
tracing::info!("Adding node {} to Patroni cluster", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop Patroni node and trigger failover
|
||||
pub async fn stop_node(&self, ip_address: &str) -> Result<()> {
|
||||
// Stop Patroni on node
|
||||
// This will trigger automatic failover to replica
|
||||
|
||||
tracing::info!("Stopping Patroni node {}", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
47
control_plane/src/docker.rs
Normal file
47
control_plane/src/docker.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use anyhow::Result;
|
||||
use crate::templates::ServiceConfig;
|
||||
use crate::server_manager::ServerInfo;
|
||||
|
||||
pub struct DockerManager;
|
||||
|
||||
impl DockerManager {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Install fail2ban via SSH
|
||||
pub async fn install_fail2ban(&self, ip_address: &str) -> Result<()> {
|
||||
tracing::info!("Installing fail2ban on {}", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure monitoring agents are running
|
||||
pub async fn ensure_monitoring(&self, ip_address: &str) -> Result<()> {
|
||||
tracing::info!("Ensuring monitoring on {}", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add worker to load balancer
|
||||
pub async fn add_worker_to_lb(&self, ip_address: &str) -> Result<()> {
|
||||
tracing::info!("Adding worker {} to load balancer", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove worker from load balancer
|
||||
pub async fn remove_worker_from_lb(&self, ip_address: &str) -> Result<()> {
|
||||
tracing::info!("Removing worker {} from load balancer", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop all services on server
|
||||
pub async fn stop_all_services(&self, ip_address: &str) -> Result<()> {
|
||||
tracing::info!("Stopping all services on {}", ip_address);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migrate Docker volume from source to target
|
||||
pub async fn migrate_volume(&self, service: &ServiceConfig, source: &ServerInfo, target: &ServerInfo) -> Result<()> {
|
||||
tracing::info!("Migrating {} from {} to {}", service.id, source.name, target.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,26 @@ use axum::{
|
||||
routing::{delete, get},
|
||||
Json, Router,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod server_manager;
|
||||
pub mod templates;
|
||||
pub mod providers;
|
||||
pub mod database;
|
||||
pub mod docker;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControlPlaneState {
|
||||
pub db: PgPool,
|
||||
pub tenant_db: PgPool,
|
||||
pub server_manager: Option<Arc<server_manager::ServerManager>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@@ -247,6 +257,21 @@ fn generate_jwt(secret: &str, role: &str) -> anyhow::Result<String> {
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Initialize the server manager for infrastructure management.
|
||||
/// Returns `None` if the necessary environment variables are not set.
|
||||
pub async fn init_server_manager(db: PgPool) -> Option<Arc<server_manager::ServerManager>> {
|
||||
let provider_config = providers::factory::ProviderConfig::from_env();
|
||||
let ssh_key = std::env::var("HETZNER_SSH_KEY_PATH").unwrap_or_default();
|
||||
|
||||
match server_manager::ServerManager::new(db, provider_config, ssh_key).await {
|
||||
Ok(sm) => Some(sm),
|
||||
Err(e) => {
|
||||
tracing::warn!("Server manager not initialized: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: ControlPlaneState) -> Router {
|
||||
Router::new()
|
||||
.route("/projects", get(list_projects).post(create_project))
|
||||
|
||||
293
control_plane/src/providers/digitalocean.rs
Normal file
293
control_plane/src/providers/digitalocean.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
use anyhow::{Result, Context};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{VpsProvider as VpsProviderEnum, VpsProviderTrait, CreateServerRequest, VpsServer, VpsPlan, VpsRegion, FirewallRule};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DoCreateRequest {
|
||||
name: String,
|
||||
region: String,
|
||||
size: String,
|
||||
image: String,
|
||||
ssh_keys: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoDropletResponse {
|
||||
droplet: DoDroplet,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoDroplet {
|
||||
id: i64,
|
||||
name: String,
|
||||
status: String,
|
||||
networks: DoNetworks,
|
||||
region: DoRegion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoNetworks {
|
||||
v4: Vec<DoNetwork>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoNetwork {
|
||||
ip_address: String,
|
||||
#[serde(rename = "type")]
|
||||
net_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoRegion {
|
||||
slug: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoListResponse {
|
||||
droplets: Vec<DoDroplet>,
|
||||
meta: DoMeta,
|
||||
links: Option<DoLinks>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoMeta {
|
||||
total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoLinks {
|
||||
pages: Option<DoPages>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoPages {
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DigitalOceanProvider {
|
||||
api_key: String,
|
||||
client: Client,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
impl DigitalOceanProvider {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
client: Client::new(),
|
||||
api_url: "https://api.digitalocean.com/v2".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn droplet_to_vps_server(droplet: &DoDroplet) -> VpsServer {
|
||||
let public_ip = droplet.networks.v4.iter()
|
||||
.find(|n| n.net_type == "public")
|
||||
.map(|n| n.ip_address.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let private_ip = droplet.networks.v4.iter()
|
||||
.find(|n| n.net_type == "private")
|
||||
.map(|n| n.ip_address.clone());
|
||||
|
||||
VpsServer {
|
||||
id: droplet.id.to_string(),
|
||||
name: droplet.name.clone(),
|
||||
status: droplet.status.clone(),
|
||||
ip_address: public_ip,
|
||||
private_ip,
|
||||
region: droplet.region.name.clone(),
|
||||
provider: VpsProviderEnum::DigitalOcean,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpsProviderTrait for DigitalOceanProvider {
|
||||
fn provider(&self) -> VpsProviderEnum {
|
||||
VpsProviderEnum::DigitalOcean
|
||||
}
|
||||
|
||||
async fn create_server(&self, request: CreateServerRequest) -> Result<VpsServer> {
|
||||
let mut tags = vec![
|
||||
format!("template:{}", request.template.id),
|
||||
"managed_by:madbase".to_string(),
|
||||
];
|
||||
if let Some(extra_tags) = request.tags {
|
||||
for (key, value) in extra_tags {
|
||||
tags.push(format!("{}:{}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
let do_request = DoCreateRequest {
|
||||
name: request.name.clone(),
|
||||
region: request.region.clone(),
|
||||
size: request.plan.clone(),
|
||||
image: "ubuntu-24-04-x64".to_string(),
|
||||
ssh_keys: request.ssh_key_id.map(|k| vec![k]).unwrap_or_default(),
|
||||
tags,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/droplets", self.api_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.json(&do_request)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create DigitalOcean droplet")?
|
||||
.json::<DoDropletResponse>()
|
||||
.await
|
||||
.context("Failed to parse DigitalOcean create response")?;
|
||||
|
||||
Ok(Self::droplet_to_vps_server(&response.droplet))
|
||||
}
|
||||
|
||||
async fn delete_server(&self, server_id: &str) -> Result<()> {
|
||||
let status = self.client
|
||||
.delete(format!("{}/droplets/{}", self.api_url, server_id))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to delete DigitalOcean droplet")?
|
||||
.status();
|
||||
|
||||
if !status.is_success() && status.as_u16() != 204 {
|
||||
return Err(anyhow::anyhow!("Failed to delete droplet {}: HTTP {}", server_id, status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_server(&self, server_id: &str) -> Result<VpsServer> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/droplets/{}", self.api_url, server_id))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await?
|
||||
.json::<DoDropletResponse>()
|
||||
.await
|
||||
.context("Failed to parse DigitalOcean get response")?;
|
||||
|
||||
Ok(Self::droplet_to_vps_server(&response.droplet))
|
||||
}
|
||||
|
||||
async fn list_servers(&self) -> Result<Vec<VpsServer>> {
|
||||
let mut all_servers = Vec::new();
|
||||
let mut page: u32 = 1;
|
||||
|
||||
loop {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/droplets?page={}&per_page=100&tag_name=managed_by:madbase", self.api_url, page))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await?
|
||||
.json::<DoListResponse>()
|
||||
.await
|
||||
.context("Failed to parse DigitalOcean list response")?;
|
||||
|
||||
for droplet in &response.droplets {
|
||||
all_servers.push(Self::droplet_to_vps_server(droplet));
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
let has_next = response.links
|
||||
.and_then(|l| l.pages)
|
||||
.and_then(|p| p.next)
|
||||
.is_some();
|
||||
|
||||
if !has_next {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
Ok(all_servers)
|
||||
}
|
||||
|
||||
async fn enable_firewall(&self, _server_id: &str, rules: Vec<FirewallRule>) -> Result<()> {
|
||||
let inbound_rules: Vec<_> = rules.into_iter().map(|rule| {
|
||||
serde_json::json!({
|
||||
"protocol": rule.protocol,
|
||||
"ports": rule.port,
|
||||
"sources": {
|
||||
"addresses": rule.source_ips,
|
||||
}
|
||||
})
|
||||
}).collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"name": format!("madbase-firewall"),
|
||||
"inbound_rules": inbound_rules,
|
||||
"outbound_rules": [{
|
||||
"protocol": "tcp",
|
||||
"ports": "all",
|
||||
"destinations": { "addresses": ["0.0.0.0/0", "::/0"] }
|
||||
}],
|
||||
"droplet_ids": [_server_id.parse::<i64>().unwrap_or(0)]
|
||||
});
|
||||
|
||||
self.client
|
||||
.post(format!("{}/firewalls", self.api_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create DigitalOcean firewall")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_available_plans(&self) -> Vec<VpsPlan> {
|
||||
vec![
|
||||
VpsPlan { id: "s-1vcpu-1gb".to_string(), name: "Basic 1GB".to_string(), cpu_cores: 1, memory_gb: 1.0, disk_gb: 25, monthly_cost: 6.0 },
|
||||
VpsPlan { id: "s-1vcpu-2gb".to_string(), name: "Basic 2GB".to_string(), cpu_cores: 1, memory_gb: 2.0, disk_gb: 50, monthly_cost: 12.0 },
|
||||
VpsPlan { id: "s-2vcpu-4gb".to_string(), name: "Basic 4GB".to_string(), cpu_cores: 2, memory_gb: 4.0, disk_gb: 80, monthly_cost: 24.0 },
|
||||
VpsPlan { id: "s-4vcpu-8gb".to_string(), name: "Basic 8GB".to_string(), cpu_cores: 4, memory_gb: 8.0, disk_gb: 160, monthly_cost: 48.0 },
|
||||
VpsPlan { id: "s-8vcpu-16gb".to_string(), name: "Basic 16GB".to_string(), cpu_cores: 8, memory_gb: 16.0, disk_gb: 320, monthly_cost: 96.0 },
|
||||
]
|
||||
}
|
||||
|
||||
fn get_available_regions(&self) -> Vec<VpsRegion> {
|
||||
vec![
|
||||
VpsRegion { id: "nyc1".to_string(), name: "New York 1".to_string(), country: "USA".to_string(), city: "New York".to_string() },
|
||||
VpsRegion { id: "nyc3".to_string(), name: "New York 3".to_string(), country: "USA".to_string(), city: "New York".to_string() },
|
||||
VpsRegion { id: "sfo3".to_string(), name: "San Francisco 3".to_string(), country: "USA".to_string(), city: "San Francisco".to_string() },
|
||||
VpsRegion { id: "ams3".to_string(), name: "Amsterdam 3".to_string(), country: "Netherlands".to_string(), city: "Amsterdam".to_string() },
|
||||
VpsRegion { id: "fra1".to_string(), name: "Frankfurt 1".to_string(), country: "Germany".to_string(), city: "Frankfurt".to_string() },
|
||||
VpsRegion { id: "lon1".to_string(), name: "London 1".to_string(), country: "UK".to_string(), city: "London".to_string() },
|
||||
VpsRegion { id: "sgp1".to_string(), name: "Singapore 1".to_string(), country: "Singapore".to_string(), city: "Singapore".to_string() },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_digitalocean_plans() {
|
||||
let provider = DigitalOceanProvider::new("test-key".to_string());
|
||||
let plans = provider.get_available_plans();
|
||||
assert!(plans.len() >= 5);
|
||||
|
||||
let basic_4gb = plans.iter().find(|p| p.id == "s-2vcpu-4gb").unwrap();
|
||||
assert_eq!(basic_4gb.memory_gb, 4.0);
|
||||
assert_eq!(basic_4gb.cpu_cores, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_digitalocean_regions() {
|
||||
let provider = DigitalOceanProvider::new("test-key".to_string());
|
||||
let regions = provider.get_available_regions();
|
||||
assert!(regions.len() >= 5);
|
||||
assert!(regions.iter().any(|r| r.id == "fra1"));
|
||||
}
|
||||
}
|
||||
84
control_plane/src/providers/factory.rs
Normal file
84
control_plane/src/providers/factory.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{VpsProvider as VpsProviderEnum, VpsProviderTrait};
|
||||
use super::hetzner::HetznerProvider;
|
||||
use super::digitalocean::DigitalOceanProvider;
|
||||
use super::generic::GenericProvider;
|
||||
|
||||
pub struct ProviderFactory;
|
||||
|
||||
impl ProviderFactory {
|
||||
pub async fn create_provider(
|
||||
provider: VpsProviderEnum,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<Arc<dyn VpsProviderTrait>> {
|
||||
match provider {
|
||||
VpsProviderEnum::Hetzner => {
|
||||
let api_key = config
|
||||
.hetzner_api_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Hetzner API key required"))?;
|
||||
Ok(Arc::new(HetznerProvider::new(api_key.clone())))
|
||||
}
|
||||
VpsProviderEnum::DigitalOcean => {
|
||||
let api_key = config
|
||||
.digital_ocean_api_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("DigitalOcean API key required"))?;
|
||||
Ok(Arc::new(DigitalOceanProvider::new(api_key.clone())))
|
||||
}
|
||||
VpsProviderEnum::Linode => {
|
||||
Ok(Arc::new(GenericProvider::new(
|
||||
config.linode_endpoint.clone(),
|
||||
config.linode_api_key.clone(),
|
||||
)))
|
||||
}
|
||||
VpsProviderEnum::Vultr => {
|
||||
Ok(Arc::new(GenericProvider::new(
|
||||
config.vultr_endpoint.clone(),
|
||||
config.vultr_api_key.clone(),
|
||||
)))
|
||||
}
|
||||
VpsProviderEnum::Generic => {
|
||||
Ok(Arc::new(GenericProvider::new(
|
||||
config.generic_endpoint.clone(),
|
||||
config.generic_api_key.clone(),
|
||||
)))
|
||||
}
|
||||
_ => {
|
||||
Ok(Arc::new(GenericProvider::new(None, None)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderConfig {
|
||||
pub hetzner_api_key: Option<String>,
|
||||
pub digital_ocean_api_key: Option<String>,
|
||||
pub digital_ocean_endpoint: Option<String>,
|
||||
pub linode_api_key: Option<String>,
|
||||
pub linode_endpoint: Option<String>,
|
||||
pub vultr_api_key: Option<String>,
|
||||
pub vultr_endpoint: Option<String>,
|
||||
pub generic_endpoint: Option<String>,
|
||||
pub generic_api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
hetzner_api_key: std::env::var("HETZNER_API_KEY").ok(),
|
||||
digital_ocean_api_key: std::env::var("DIGITALOCEAN_API_KEY").ok()
|
||||
.or_else(|| std::env::var("DO_API_TOKEN").ok()),
|
||||
digital_ocean_endpoint: std::env::var("DIGITALOCEAN_ENDPOINT").ok(),
|
||||
linode_api_key: std::env::var("LINODE_API_KEY").ok(),
|
||||
linode_endpoint: std::env::var("LINODE_ENDPOINT").ok(),
|
||||
vultr_api_key: std::env::var("VULTR_API_KEY").ok(),
|
||||
vultr_endpoint: std::env::var("VULTR_ENDPOINT").ok(),
|
||||
generic_endpoint: std::env::var("GENERIC_ENDPOINT").ok(),
|
||||
generic_api_key: std::env::var("GENERIC_API_KEY").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
74
control_plane/src/providers/generic.rs
Normal file
74
control_plane/src/providers/generic.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{VpsProvider as VpsProviderEnum, VpsProviderTrait, CreateServerRequest, VpsServer, VpsPlan, VpsRegion, FirewallRule};
|
||||
|
||||
/// Generic provider for unsupported VPS hosts
|
||||
/// Manages servers manually but provides same interface
|
||||
pub struct GenericProvider {
|
||||
api_endpoint: Option<String>,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl GenericProvider {
|
||||
pub fn new(api_endpoint: Option<String>, api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
api_endpoint,
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpsProviderTrait for GenericProvider {
|
||||
fn provider(&self) -> VpsProviderEnum {
|
||||
VpsProviderEnum::Generic
|
||||
}
|
||||
|
||||
async fn create_server(&self, _request: CreateServerRequest) -> Result<VpsServer> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Generic provider requires manual server provisioning. \
|
||||
Please create a server manually and register it using the API."
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_server(&self, _server_id: &str) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Generic provider requires manual server deletion. \
|
||||
Please delete the server through your VPS provider's control panel."
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_server(&self, _server_id: &str) -> Result<VpsServer> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Generic provider does not support automatic server retrieval. \
|
||||
Please ensure the server is accessible."
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_servers(&self) -> Result<Vec<VpsServer>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn enable_firewall(&self, _server_id: &str, _rules: Vec<FirewallRule>) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Generic provider requires manual firewall configuration. \
|
||||
Please configure firewall rules through your VPS provider's control panel."
|
||||
))
|
||||
}
|
||||
|
||||
fn get_available_plans(&self) -> Vec<VpsPlan> {
|
||||
vec![
|
||||
VpsPlan { id: "small".to_string(), name: "Small (1-2GB RAM)".to_string(), cpu_cores: 1, memory_gb: 2.0, disk_gb: 40, monthly_cost: 5.0 },
|
||||
VpsPlan { id: "medium".to_string(), name: "Medium (4GB RAM)".to_string(), cpu_cores: 2, memory_gb: 4.0, disk_gb: 80, monthly_cost: 10.0 },
|
||||
VpsPlan { id: "large".to_string(), name: "Large (8GB RAM)".to_string(), cpu_cores: 4, memory_gb: 8.0, disk_gb: 160, monthly_cost: 20.0 },
|
||||
]
|
||||
}
|
||||
|
||||
fn get_available_regions(&self) -> Vec<VpsRegion> {
|
||||
vec![
|
||||
VpsRegion { id: "us-east".to_string(), name: "US East".to_string(), country: "USA".to_string(), city: "Various".to_string() },
|
||||
VpsRegion { id: "eu-west".to_string(), name: "EU West".to_string(), country: "Various".to_string(), city: "Various".to_string() },
|
||||
]
|
||||
}
|
||||
}
|
||||
337
control_plane/src/providers/hetzner.rs
Normal file
337
control_plane/src/providers/hetzner.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use anyhow::{Result, Context};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{VpsProvider as VpsProviderEnum, VpsProviderTrait, CreateServerRequest, VpsServer, VpsPlan, VpsRegion, FirewallRule};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HetznerCreateRequest {
|
||||
name: String,
|
||||
server_type: String,
|
||||
image: String,
|
||||
location: Option<String>,
|
||||
ssh_keys: Vec<String>,
|
||||
labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerResponse {
|
||||
server: HetznerServer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerServer {
|
||||
id: i64,
|
||||
name: String,
|
||||
status: String,
|
||||
public_net: HetznerPublicNet,
|
||||
private_net: Vec<HetznerPrivateNet>,
|
||||
datacenter: Option<HetznerDatacenter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerPrivateNet {
|
||||
ip: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerPublicNet {
|
||||
ipv4: HetznerIPv4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct HetznerIPv4 {
|
||||
ip: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerDatacenter {
|
||||
location: HetznerLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct HetznerLocation {
|
||||
name: String,
|
||||
country: String,
|
||||
city: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerListResponse {
|
||||
servers: Vec<HetznerServer>,
|
||||
meta: HetznerMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerMeta {
|
||||
pagination: HetznerPagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HetznerPagination {
|
||||
next_page: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct HetznerProvider {
|
||||
api_key: String,
|
||||
client: Client,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
impl HetznerProvider {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
client: Client::new(),
|
||||
api_url: "https://api.hetzner.cloud/v1".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VpsProviderTrait for HetznerProvider {
|
||||
fn provider(&self) -> VpsProviderEnum {
|
||||
VpsProviderEnum::Hetzner
|
||||
}
|
||||
|
||||
async fn create_server(&self, request: CreateServerRequest) -> Result<VpsServer> {
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("template".to_string(), request.template.id.clone());
|
||||
labels.insert("managed_by".to_string(), "madbase-control-plane".to_string());
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
for (key, value) in tags {
|
||||
labels.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
let hetzner_request = HetznerCreateRequest {
|
||||
name: request.name.clone(),
|
||||
server_type: request.plan.clone(),
|
||||
image: "ubuntu-24.04".to_string(),
|
||||
location: Some(request.region.clone()),
|
||||
ssh_keys: request.ssh_key_id.map(|k| vec![k]).unwrap_or_default(),
|
||||
labels,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/servers", self.api_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.json(&hetzner_request)
|
||||
.send()
|
||||
.await?
|
||||
.json::<HetznerResponse>()
|
||||
.await?;
|
||||
|
||||
let server = response.server;
|
||||
let region = server.datacenter
|
||||
.map(|dc| format!("{} - {}", dc.location.city, dc.location.country))
|
||||
.unwrap_or_else(|| request.region.clone());
|
||||
|
||||
Ok(VpsServer {
|
||||
id: server.id.to_string(),
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
ip_address: server.public_net.ipv4.ip,
|
||||
private_ip: server.private_net.first().map(|n| n.ip.clone()),
|
||||
region,
|
||||
provider: VpsProviderEnum::Hetzner,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_server(&self, server_id: &str) -> Result<()> {
|
||||
self.client
|
||||
.delete(format!("{}/servers/{}", self.api_url, server_id))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to delete Hetzner server")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_server(&self, server_id: &str) -> Result<VpsServer> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/servers/{}", self.api_url, server_id))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await?
|
||||
.json::<HetznerResponse>()
|
||||
.await?;
|
||||
|
||||
let server = response.server;
|
||||
|
||||
Ok(VpsServer {
|
||||
id: server.id.to_string(),
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
ip_address: server.public_net.ipv4.ip,
|
||||
private_ip: server.private_net.first().map(|n| n.ip.clone()),
|
||||
region: server.datacenter
|
||||
.map(|dc| format!("{} - {}", dc.location.city, dc.location.country))
|
||||
.unwrap_or_default(),
|
||||
provider: VpsProviderEnum::Hetzner,
|
||||
})
|
||||
}
|
||||
|
||||
/// List servers with pagination (Hetzner max 50 per page)
|
||||
async fn list_servers(&self) -> Result<Vec<VpsServer>> {
|
||||
let mut all_servers = Vec::new();
|
||||
let mut page: u32 = 1;
|
||||
|
||||
loop {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/servers?page={}&per_page=50", self.api_url, page))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await?
|
||||
.json::<HetznerListResponse>()
|
||||
.await?;
|
||||
|
||||
for server in response.servers {
|
||||
all_servers.push(VpsServer {
|
||||
id: server.id.to_string(),
|
||||
name: server.name.clone(),
|
||||
status: server.status.clone(),
|
||||
ip_address: server.public_net.ipv4.ip.clone(),
|
||||
private_ip: server.private_net.first().map(|n| n.ip.clone()),
|
||||
region: server.datacenter
|
||||
.as_ref()
|
||||
.map(|dc| format!("{} - {}", dc.location.city, dc.location.country))
|
||||
.unwrap_or_default(),
|
||||
provider: VpsProviderEnum::Hetzner,
|
||||
});
|
||||
}
|
||||
|
||||
match response.meta.pagination.next_page {
|
||||
Some(next) => page = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_servers)
|
||||
}
|
||||
|
||||
async fn enable_firewall(&self, server_id: &str, rules: Vec<FirewallRule>) -> Result<()> {
|
||||
let firewall_rules: Vec<_> = rules.into_iter().map(|rule| {
|
||||
serde_json::json!({
|
||||
"direction": rule.direction,
|
||||
"source_ips": rule.source_ips,
|
||||
"destination_ips": [],
|
||||
"protocol": rule.protocol,
|
||||
"port": rule.port
|
||||
})
|
||||
}).collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"firewall": {
|
||||
"name": format!("madbase-{}", server_id),
|
||||
"apply_to": [{"type": "server", "server": server_id}],
|
||||
"rules": firewall_rules
|
||||
}
|
||||
});
|
||||
|
||||
self.client
|
||||
.post(format!("{}/firewalls", self.api_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create Hetzner firewall")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Corrected Hetzner plans: CX11=2GB, CX21=4GB
|
||||
fn get_available_plans(&self) -> Vec<VpsPlan> {
|
||||
vec![
|
||||
VpsPlan {
|
||||
id: "cx11".to_string(),
|
||||
name: "CX11".to_string(),
|
||||
cpu_cores: 1,
|
||||
memory_gb: 2.0,
|
||||
disk_gb: 20,
|
||||
monthly_cost: 3.69,
|
||||
},
|
||||
VpsPlan {
|
||||
id: "cx21".to_string(),
|
||||
name: "CX21".to_string(),
|
||||
cpu_cores: 2,
|
||||
memory_gb: 4.0,
|
||||
disk_gb: 40,
|
||||
monthly_cost: 6.94,
|
||||
},
|
||||
VpsPlan {
|
||||
id: "cx31".to_string(),
|
||||
name: "CX31".to_string(),
|
||||
cpu_cores: 2,
|
||||
memory_gb: 8.0,
|
||||
disk_gb: 80,
|
||||
monthly_cost: 14.21,
|
||||
},
|
||||
VpsPlan {
|
||||
id: "cx41".to_string(),
|
||||
name: "CX41".to_string(),
|
||||
cpu_cores: 4,
|
||||
memory_gb: 16.0,
|
||||
disk_gb: 160,
|
||||
monthly_cost: 25.60,
|
||||
},
|
||||
VpsPlan {
|
||||
id: "cpx11".to_string(),
|
||||
name: "CPX11".to_string(),
|
||||
cpu_cores: 2,
|
||||
memory_gb: 2.0,
|
||||
disk_gb: 40,
|
||||
monthly_cost: 4.28,
|
||||
},
|
||||
VpsPlan {
|
||||
id: "ccx11".to_string(),
|
||||
name: "CCX11".to_string(),
|
||||
cpu_cores: 2,
|
||||
memory_gb: 8.0,
|
||||
disk_gb: 80,
|
||||
monthly_cost: 9.73,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn get_available_regions(&self) -> Vec<VpsRegion> {
|
||||
vec![
|
||||
VpsRegion { id: "fsn1".to_string(), name: "Falkenstein DC 1".to_string(), country: "Germany".to_string(), city: "Falkenstein".to_string() },
|
||||
VpsRegion { id: "nbg1".to_string(), name: "Nuremberg DC 1".to_string(), country: "Germany".to_string(), city: "Nuremberg".to_string() },
|
||||
VpsRegion { id: "hel1".to_string(), name: "Helsinki DC 1".to_string(), country: "Finland".to_string(), city: "Helsinki".to_string() },
|
||||
VpsRegion { id: "ash".to_string(), name: "Ashburn, VA".to_string(), country: "USA".to_string(), city: "Ashburn".to_string() },
|
||||
VpsRegion { id: "hil".to_string(), name: "Hillsboro, OR".to_string(), country: "USA".to_string(), city: "Hillsboro".to_string() },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hetzner_plan_ram_values() {
|
||||
let provider = HetznerProvider::new("test-key".to_string());
|
||||
let plans = provider.get_available_plans();
|
||||
|
||||
let cx11 = plans.iter().find(|p| p.id == "cx11").unwrap();
|
||||
assert_eq!(cx11.memory_gb, 2.0, "CX11 should have 2GB RAM");
|
||||
assert_eq!(cx11.cpu_cores, 1);
|
||||
|
||||
let cx21 = plans.iter().find(|p| p.id == "cx21").unwrap();
|
||||
assert_eq!(cx21.memory_gb, 4.0, "CX21 should have 4GB RAM");
|
||||
|
||||
let cx31 = plans.iter().find(|p| p.id == "cx31").unwrap();
|
||||
assert_eq!(cx31.memory_gb, 8.0, "CX31 should have 8GB RAM");
|
||||
|
||||
let cx41 = plans.iter().find(|p| p.id == "cx41").unwrap();
|
||||
assert_eq!(cx41.memory_gb, 16.0, "CX41 should have 16GB RAM");
|
||||
}
|
||||
}
|
||||
174
control_plane/src/providers/mod.rs
Normal file
174
control_plane/src/providers/mod.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
pub mod hetzner;
|
||||
pub mod generic;
|
||||
pub mod digitalocean;
|
||||
pub mod factory;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::templates::TemplateConfig;
|
||||
|
||||
/// Common VPS server response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpsServer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub ip_address: String,
|
||||
pub private_ip: Option<String>,
|
||||
pub region: String,
|
||||
pub provider: VpsProvider,
|
||||
}
|
||||
|
||||
/// VPS provider types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VpsProvider {
|
||||
Hetzner,
|
||||
DigitalOcean,
|
||||
Linode,
|
||||
Vultr,
|
||||
Aws,
|
||||
Gcp,
|
||||
Azure,
|
||||
OVH,
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for VpsProvider {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"hetzner" => Ok(VpsProvider::Hetzner),
|
||||
"digitalocean" => Ok(VpsProvider::DigitalOcean),
|
||||
"linode" => Ok(VpsProvider::Linode),
|
||||
"vultr" => Ok(VpsProvider::Vultr),
|
||||
"aws" => Ok(VpsProvider::Aws),
|
||||
"gcp" => Ok(VpsProvider::Gcp),
|
||||
"azure" => Ok(VpsProvider::Azure),
|
||||
"ovh" => Ok(VpsProvider::OVH),
|
||||
"generic" => Ok(VpsProvider::Generic),
|
||||
_ => Err(anyhow::anyhow!("Unknown provider: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VpsProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VpsProvider::Hetzner => write!(f, "hetzner"),
|
||||
VpsProvider::DigitalOcean => write!(f, "digitalocean"),
|
||||
VpsProvider::Linode => write!(f, "linode"),
|
||||
VpsProvider::Vultr => write!(f, "vultr"),
|
||||
VpsProvider::Aws => write!(f, "aws"),
|
||||
VpsProvider::Gcp => write!(f, "gcp"),
|
||||
VpsProvider::Azure => write!(f, "azure"),
|
||||
VpsProvider::OVH => write!(f, "ovh"),
|
||||
VpsProvider::Generic => write!(f, "generic"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common VPS plan representation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpsPlan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cpu_cores: u32,
|
||||
pub memory_gb: f64,
|
||||
pub disk_gb: u32,
|
||||
pub monthly_cost: f64,
|
||||
}
|
||||
|
||||
/// Create server request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateServerRequest {
|
||||
pub name: String,
|
||||
pub plan: String,
|
||||
pub region: String,
|
||||
pub template: TemplateConfig,
|
||||
pub ssh_key_id: Option<String>,
|
||||
pub tags: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Common provider trait for all VPS hosts
|
||||
#[async_trait]
|
||||
pub trait VpsProviderTrait: Send + Sync {
|
||||
fn provider(&self) -> VpsProvider;
|
||||
async fn create_server(&self, request: CreateServerRequest) -> Result<VpsServer>;
|
||||
async fn delete_server(&self, server_id: &str) -> Result<()>;
|
||||
async fn get_server(&self, server_id: &str) -> Result<VpsServer>;
|
||||
async fn list_servers(&self) -> Result<Vec<VpsServer>>;
|
||||
async fn enable_firewall(&self, server_id: &str, rules: Vec<FirewallRule>) -> Result<()>;
|
||||
fn get_available_plans(&self) -> Vec<VpsPlan>;
|
||||
fn get_available_regions(&self) -> Vec<VpsRegion>;
|
||||
|
||||
/// Validate plan is compatible with template — corrected RAM mapping
|
||||
fn validate_plan(&self, plan: &str, template: &TemplateConfig) -> Result<()> {
|
||||
let plans = self.get_available_plans();
|
||||
let plan_obj = plans.iter()
|
||||
.find(|p| p.id == plan || p.name == plan)
|
||||
.ok_or_else(|| anyhow::anyhow!("Plan {} not found", plan))?;
|
||||
|
||||
// Corrected RAM requirements: CX11=2GB, CX21=4GB, CX31=8GB, CX41=16GB
|
||||
let min_ram = match template.min_hetzner_plan.as_str() {
|
||||
"CX11" => 2.0,
|
||||
"CX21" => 4.0,
|
||||
"CX31" => 8.0,
|
||||
"CX41" => 16.0,
|
||||
_ => 2.0,
|
||||
};
|
||||
|
||||
if plan_obj.memory_gb < min_ram {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Plan {} has {}GB RAM, but template {} requires at least {}GB",
|
||||
plan, plan_obj.memory_gb, template.id, min_ram
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Firewall rule
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FirewallRule {
|
||||
pub direction: String,
|
||||
pub protocol: String,
|
||||
pub port: String,
|
||||
pub source_ips: Vec<String>,
|
||||
}
|
||||
|
||||
/// VPS region
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpsRegion {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub country: String,
|
||||
pub city: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hetzner_plan_validation_ram() {
|
||||
// Verify the corrected RAM mapping
|
||||
assert_eq!(match "CX11" { "CX11" => 2.0_f64, _ => 0.0 }, 2.0);
|
||||
assert_eq!(match "CX21" { "CX21" => 4.0_f64, _ => 0.0 }, 4.0);
|
||||
assert_eq!(match "CX31" { "CX31" => 8.0_f64, _ => 0.0 }, 8.0);
|
||||
assert_eq!(match "CX41" { "CX41" => 16.0_f64, _ => 0.0 }, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_from_str() {
|
||||
assert_eq!("hetzner".parse::<VpsProvider>().unwrap(), VpsProvider::Hetzner);
|
||||
assert_eq!("digitalocean".parse::<VpsProvider>().unwrap(), VpsProvider::DigitalOcean);
|
||||
assert_eq!("generic".parse::<VpsProvider>().unwrap(), VpsProvider::Generic);
|
||||
assert!("unknown".parse::<VpsProvider>().is_err());
|
||||
}
|
||||
}
|
||||
1002
control_plane/src/server_manager.rs
Normal file
1002
control_plane/src/server_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
511
control_plane/src/templates.rs
Normal file
511
control_plane/src/templates.rs
Normal file
@@ -0,0 +1,511 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub version: String,
|
||||
pub min_hetzner_plan: String,
|
||||
#[serde(rename = "min_hetzner_plan_num")]
|
||||
pub min_hetzner_plan_num: u32,
|
||||
#[serde(rename = "estimated_monthly_cost")]
|
||||
pub estimated_monthly_cost: f64,
|
||||
pub services: Vec<ServiceConfig>,
|
||||
pub requirements: TemplateRequirements,
|
||||
#[serde(rename = "estimated_time_minutes")]
|
||||
pub estimated_time_minutes: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
pub ports: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub environment: Vec<EnvVar>,
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<String>,
|
||||
#[serde(rename = "resource_profile", default)]
|
||||
pub resource_profile: String,
|
||||
#[serde(rename = "has_persistent_data", default)]
|
||||
pub has_persistent_data: bool,
|
||||
#[serde(rename = "is_critical", default)]
|
||||
pub is_critical: bool,
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnvVar {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateRequirements {
|
||||
#[serde(rename = "min_nodes")]
|
||||
pub min_nodes: i32,
|
||||
#[serde(rename = "max_nodes")]
|
||||
pub max_nodes: i32,
|
||||
#[serde(rename = "supports_ha", default)]
|
||||
pub supports_ha: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TemplateValidation {
|
||||
pub valid: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl TemplateConfig {
|
||||
/// Load all available templates
|
||||
pub async fn all_templates() -> Vec<TemplateConfig> {
|
||||
vec![
|
||||
Self::db_node_template(),
|
||||
Self::worker_node_template(),
|
||||
Self::control_plane_node_template(),
|
||||
Self::monitoring_node_template(),
|
||||
Self::worker_db_combo_template(),
|
||||
Self::worker_monitor_combo_template(),
|
||||
Self::all_in_one_template(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Load template by ID
|
||||
pub async fn from_template_id(id: &str) -> Result<Self> {
|
||||
let templates = Self::all_templates().await;
|
||||
templates.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Template not found: {}", id))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> TemplateValidation {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if self.min_hetzner_plan_num < 11 {
|
||||
warnings.push("Plan CX11 is minimum recommended".to_string());
|
||||
}
|
||||
|
||||
if self.services.is_empty() {
|
||||
warnings.push("Template has no services".to_string());
|
||||
}
|
||||
|
||||
if self.requirements.max_nodes > 1 && !self.requirements.supports_ha {
|
||||
warnings.push("Multiple nodes but HA not supported".to_string());
|
||||
}
|
||||
|
||||
TemplateValidation {
|
||||
valid: warnings.is_empty(),
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
// Template definitions
|
||||
|
||||
fn db_node_template() -> Self {
|
||||
Self {
|
||||
id: "db-node".to_string(),
|
||||
name: "Database Node".to_string(),
|
||||
description: "PostgreSQL with Patroni for HA clustering".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX21".to_string(),
|
||||
min_hetzner_plan_num: 21,
|
||||
estimated_monthly_cost: 6.94,
|
||||
estimated_time_minutes: 15,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "postgresql".to_string(),
|
||||
name: "PostgreSQL".to_string(),
|
||||
image: "registry.gitlab.com/postgres-ai/postgresql-autobase/patroni:3.0.2".to_string(),
|
||||
ports: vec!["5432:5432".to_string(), "8008:8008".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["postgres_data:/var/lib/postgresql/data".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "etcd".to_string(),
|
||||
name: "etcd".to_string(),
|
||||
image: "quay.io/coreos/etcd:v3.5.9".to_string(),
|
||||
ports: vec!["2379:2379".to_string(), "2380:2380".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["etcd_data:/etcd-data".to_string()],
|
||||
resource_profile: "minimal".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "haproxy".to_string(),
|
||||
name: "HAProxy".to_string(),
|
||||
image: "haproxy:2.8-alpine".to_string(),
|
||||
ports: vec!["5433:5433".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "minimal".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 3,
|
||||
max_nodes: 7,
|
||||
supports_ha: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_node_template() -> Self {
|
||||
Self {
|
||||
id: "worker-node".to_string(),
|
||||
name: "Worker Node".to_string(),
|
||||
description: "API worker nodes for horizontal scaling".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX11".to_string(),
|
||||
min_hetzner_plan_num: 11,
|
||||
estimated_monthly_cost: 3.69,
|
||||
estimated_time_minutes: 10,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "worker".to_string(),
|
||||
name: "MadBase Worker".to_string(),
|
||||
image: "madbase/worker:latest".to_string(),
|
||||
ports: vec!["8002:8002".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "cpu_intensive".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "vmagent".to_string(),
|
||||
name: "VictoriaMetrics Agent".to_string(),
|
||||
image: "victoriametrics/vmagent:latest".to_string(),
|
||||
ports: vec!["8429:8429".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["./config/vmagent.yml:/etc/vmagent/prometheus.yml:ro".to_string()],
|
||||
resource_profile: "minimal".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: false,
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 1,
|
||||
max_nodes: 20,
|
||||
supports_ha: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn control_plane_node_template() -> Self {
|
||||
Self {
|
||||
id: "control-plane-node".to_string(),
|
||||
name: "Control Plane Node".to_string(),
|
||||
description: "Management APIs and Studio UI".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX11".to_string(),
|
||||
min_hetzner_plan_num: 11,
|
||||
estimated_monthly_cost: 3.69,
|
||||
estimated_time_minutes: 12,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "proxy".to_string(),
|
||||
name: "Gateway Proxy".to_string(),
|
||||
image: "madbase/proxy:latest".to_string(),
|
||||
ports: vec!["8080:8080".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "control".to_string(),
|
||||
name: "Control Plane API".to_string(),
|
||||
image: "madbase/control:latest".to_string(),
|
||||
ports: vec!["8001:8001".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "grafana".to_string(),
|
||||
name: "Grafana".to_string(),
|
||||
image: "grafana/grafana:latest".to_string(),
|
||||
ports: vec!["3030:3030".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["grafana_data:/var/lib/grafana".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: false,
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 1,
|
||||
max_nodes: 2,
|
||||
supports_ha: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn monitoring_node_template() -> Self {
|
||||
Self {
|
||||
id: "monitoring-node".to_string(),
|
||||
name: "Monitoring Node".to_string(),
|
||||
description: "Centralized metrics and logging".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX11".to_string(),
|
||||
min_hetzner_plan_num: 11,
|
||||
estimated_monthly_cost: 3.69,
|
||||
estimated_time_minutes: 10,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "victoriametrics".to_string(),
|
||||
name: "VictoriaMetrics".to_string(),
|
||||
image: "victoriametrics/victoria-metrics:latest".to_string(),
|
||||
ports: vec!["8428:8428".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["vm_data:/victoria-metrics-data".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "loki".to_string(),
|
||||
name: "Loki".to_string(),
|
||||
image: "grafana/loki:latest".to_string(),
|
||||
ports: vec!["3100:3100".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["loki_data:/loki".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 1,
|
||||
max_nodes: 2,
|
||||
supports_ha: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_db_combo_template() -> Self {
|
||||
Self {
|
||||
id: "worker-db-combo".to_string(),
|
||||
name: "Worker + Database Combo".to_string(),
|
||||
description: "Combined worker and database node for smaller deployments".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX31".to_string(),
|
||||
min_hetzner_plan_num: 31,
|
||||
estimated_monthly_cost: 14.21,
|
||||
estimated_time_minutes: 20,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "postgresql".to_string(),
|
||||
name: "PostgreSQL".to_string(),
|
||||
image: "registry.gitlab.com/postgres-ai/postgresql-autobase/patroni:3.0.2".to_string(),
|
||||
ports: vec!["5432:5432".to_string(), "8008:8008".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["postgres_data:/var/lib/postgresql/data".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "etcd".to_string(),
|
||||
name: "etcd".to_string(),
|
||||
image: "quay.io/coreos/etcd:v3.5.9".to_string(),
|
||||
ports: vec!["2379:2379".to_string(), "2380:2380".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["etcd_data:/etcd-data".to_string()],
|
||||
resource_profile: "minimal".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "haproxy".to_string(),
|
||||
name: "HAProxy".to_string(),
|
||||
image: "haproxy:2.8-alpine".to_string(),
|
||||
ports: vec!["5433:5433".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "minimal".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "worker".to_string(),
|
||||
name: "MadBase Worker".to_string(),
|
||||
image: "madbase/worker:latest".to_string(),
|
||||
ports: vec!["8002:8002".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "cpu_intensive".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "vmagent".to_string(),
|
||||
name: "VictoriaMetrics Agent".to_string(),
|
||||
image: "victoriametrics/vmagent:latest".to_string(),
|
||||
ports: vec!["8429:8429".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["./config/vmagent.yml:/etc/vmagent/prometheus.yml:ro".to_string()],
|
||||
resource_profile: "minimal".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 1,
|
||||
max_nodes: 2,
|
||||
supports_ha: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_monitor_combo_template() -> Self {
|
||||
Self {
|
||||
id: "worker-monitor-combo".to_string(),
|
||||
name: "Worker + Monitoring Combo".to_string(),
|
||||
description: "Worker node with local VictoriaMetrics and Loki".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX21".to_string(),
|
||||
min_hetzner_plan_num: 21,
|
||||
estimated_monthly_cost: 6.94,
|
||||
estimated_time_minutes: 15,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "worker".to_string(),
|
||||
name: "MadBase Worker".to_string(),
|
||||
image: "madbase/worker:latest".to_string(),
|
||||
ports: vec!["8002:8002".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "cpu_intensive".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "victoriametrics".to_string(),
|
||||
name: "VictoriaMetrics".to_string(),
|
||||
image: "victoriametrics/victoria-metrics:latest".to_string(),
|
||||
ports: vec!["8428:8428".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["vm_data:/victoria-metrics-data".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "loki".to_string(),
|
||||
name: "Loki".to_string(),
|
||||
image: "grafana/loki:latest".to_string(),
|
||||
ports: vec!["3100:3100".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["loki_data:/loki".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: false,
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 1,
|
||||
max_nodes: 3,
|
||||
supports_ha: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn all_in_one_template() -> Self {
|
||||
Self {
|
||||
id: "all-in-one".to_string(),
|
||||
name: "All-in-One Development Node".to_string(),
|
||||
description: "Complete MadBase stack on a single server".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
min_hetzner_plan: "CX41".to_string(),
|
||||
min_hetzner_plan_num: 41,
|
||||
estimated_monthly_cost: 25.60,
|
||||
estimated_time_minutes: 25,
|
||||
services: vec![
|
||||
ServiceConfig {
|
||||
id: "postgresql".to_string(),
|
||||
name: "PostgreSQL".to_string(),
|
||||
image: "registry.gitlab.com/postgres-ai/postgresql-autobase/patroni:3.0.2".to_string(),
|
||||
ports: vec!["5432:5432".to_string(), "8008:8008".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec!["postgres_data:/var/lib/postgresql/data".to_string()],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: true,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "worker".to_string(),
|
||||
name: "MadBase Worker".to_string(),
|
||||
image: "madbase/worker:latest".to_string(),
|
||||
ports: vec!["8002:8002".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "cpu_intensive".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "proxy".to_string(),
|
||||
name: "Gateway Proxy".to_string(),
|
||||
image: "madbase/proxy:latest".to_string(),
|
||||
ports: vec!["8080:8080".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
ServiceConfig {
|
||||
id: "control".to_string(),
|
||||
name: "Control Plane API".to_string(),
|
||||
image: "madbase/control:latest".to_string(),
|
||||
ports: vec!["8001:8001".to_string()],
|
||||
environment: vec![],
|
||||
volumes: vec![],
|
||||
resource_profile: "balanced".to_string(),
|
||||
has_persistent_data: false,
|
||||
is_critical: true,
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
requirements: TemplateRequirements {
|
||||
min_nodes: 1,
|
||||
max_nodes: 1,
|
||||
supports_ha: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user