chore: full stack stability and migration fixes, plus react UI progress
Some checks failed
CI / podman-build (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-03-18 09:01:38 +02:00
parent 38cab8c246
commit a66d908eff
142 changed files with 12210 additions and 3402 deletions

View File

@@ -18,3 +18,6 @@ base64 = "0.21"
jsonwebtoken = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
async-trait = "0.1"
tower-http = { version = "0.6.8", features = ["fs"] }

View 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(())
}
}

View 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(())
}
}

View File

@@ -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))

View 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"));
}
}

View 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(),
}
}
}

View 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() },
]
}
}

View 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");
}
}

View 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());
}
}

File diff suppressed because it is too large Load Diff

View 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,
},
}
}
}