diff --git a/Cargo.lock b/Cargo.lock index a4d3ce4..8b78390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,6 +2377,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "optfield" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -5442,6 +5453,7 @@ dependencies = [ "migration", "mime_guess", "once_cell", + "optfield", "reqwest", "sea-orm", "serde", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 4af67d5..0a3b4df 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -31,6 +31,7 @@ uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } tower-http = { version = "0.6.8", features = ["cors"] } reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] } serde_urlencoded = { version = "0.7.1" } +optfield = { version = "0.4.0" } [dev-dependencies] tempfile = "3" diff --git a/apps/api/src/helpers.rs b/apps/api/src/helpers.rs index 6fbb533..4cf5a18 100644 --- a/apps/api/src/helpers.rs +++ b/apps/api/src/helpers.rs @@ -1,2 +1,3 @@ pub mod constants; pub mod database; +pub mod macros; diff --git a/apps/api/src/helpers/database.rs b/apps/api/src/helpers/database.rs index da8f12c..757121c 100644 --- a/apps/api/src/helpers/database.rs +++ b/apps/api/src/helpers/database.rs @@ -11,3 +11,20 @@ macro_rules! with_conn { } }}; } + +pub struct Filters { + pub pagination: Option, +} + +pub struct PaginationFilter { + pub page: u64, + pub per_page: u64, +} + +impl PaginationFilter { + pub fn get_offset_limit(&self) -> (u64, u64) { + let offset = (self.page - 1) * self.per_page; + let limit = self.per_page; + (offset, limit) + } +} diff --git a/apps/api/src/helpers/macros.rs b/apps/api/src/helpers/macros.rs new file mode 100644 index 0000000..22bf75c --- /dev/null +++ b/apps/api/src/helpers/macros.rs @@ -0,0 +1,9 @@ +#[macro_export] +macro_rules! set_if_some { + ($field:expr) => { + match $field { + Some(value) => sea_orm::ActiveValue::Set(value), + None => sea_orm::ActiveValue::NotSet, + } + }; +} diff --git a/apps/api/src/routes/api/helper/pagination.rs b/apps/api/src/routes/api/helper/pagination.rs index 4228be0..8fca585 100644 --- a/apps/api/src/routes/api/helper/pagination.rs +++ b/apps/api/src/routes/api/helper/pagination.rs @@ -4,7 +4,9 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, utoipa::ToSchema)] +use crate::helpers::database::PaginationFilter; + +#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)] /// Pagination parameters for API requests pub struct Pagination { /// Page number (1-based) @@ -22,6 +24,15 @@ impl Default for Pagination { } } +impl From for PaginationFilter { + fn from(pagination: Pagination) -> Self { + Self { + page: pagination.page as u64, + per_page: pagination.per_page as u64, + } + } +} + #[derive(Serialize, Deserialize, utoipa::ToSchema)] /// Pagination information included in API responses pub struct PaginationInfo { diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index 8960c58..aa61504 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -1,5 +1,6 @@ pub mod agent_client; pub mod auth; +pub mod nginx; pub mod server_state; pub mod settings; @@ -15,6 +16,7 @@ use crate::{ authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy}, user::{UserService, UserServiceImpl}, }, + nginx::NginxService, server_state::{ServerStateService, ServerStateStore}, settings::{SettingsService, SettingsStore}, }, @@ -28,6 +30,8 @@ pub struct AppService { pub user: ServiceState, pub server_state: ServiceState, #[allow(dead_code)] + pub nginx: ServiceState, + #[allow(dead_code)] pub agent_client: ServiceState, } @@ -47,6 +51,7 @@ pub fn get_app_service( )), }, user: Arc::new(UserServiceImpl::new(db_connection.clone())), + nginx: Arc::new(NginxService::new(db_connection.clone())), agent_client: Arc::new(agent_client::AgentService::new(Configuration::from( settings.agent.clone(), ))), diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs new file mode 100644 index 0000000..383c4cc --- /dev/null +++ b/apps/api/src/services/nginx.rs @@ -0,0 +1,31 @@ +pub mod builder; +pub mod info; +pub mod traits; + +pub mod upstream; + +use std::sync::Arc; + +use sea_orm::DatabaseConnection; + +use upstream::UpstreamService; + +pub struct NginxService { + connection: Arc, + // + upstream_service: Arc, +} + +impl NginxService { + pub fn new(connection: Arc) -> Self { + Self { + connection: connection.clone(), + // + upstream_service: Arc::new(UpstreamService::new(connection.clone())), + } + } + + pub fn get_upstream_service(&self) -> Arc { + self.upstream_service.clone() + } +} diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs new file mode 100644 index 0000000..972d0b7 --- /dev/null +++ b/apps/api/src/services/nginx/builder.rs @@ -0,0 +1,47 @@ +use crate::services::nginx::info::upstream::UpstreamInfo; + +pub const INDENT_SIZE: usize = 2; + +pub trait NginxConfigProvider { + fn to_nginx_config(&self, indent: Option) -> String; +} + +pub struct NginxConfigBuilder { + upstreams: Vec, +} + +impl NginxConfigBuilder { + pub fn new() -> Self { + Self { + upstreams: Vec::new(), + } + } + + pub fn add_upstream(&mut self, upstream: UpstreamInfo) { + self.upstreams.push(upstream); + } + + pub fn add_upstreams(&mut self, upstreams: Vec) { + for upstream in upstreams { + self.add_upstream(upstream); + } + } +} + +impl NginxConfigProvider for NginxConfigBuilder { + fn to_nginx_config(&self, indent: Option) -> String { + let mut config = format!( + "# Nginx Config Generated by YANPM at {}", + chrono::Utc::now() + ); + + for upstream in &self.upstreams { + config.push('\n'); + config.push_str(&upstream.to_nginx_config(indent)); + } + + // TODO: Add other sections like servers, locations, etc. + + config + } +} diff --git a/apps/api/src/services/nginx/info.rs b/apps/api/src/services/nginx/info.rs new file mode 100644 index 0000000..b74b8b1 --- /dev/null +++ b/apps/api/src/services/nginx/info.rs @@ -0,0 +1,2 @@ +pub mod upstream; +pub mod upstream_target; diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs new file mode 100644 index 0000000..1756e6d --- /dev/null +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -0,0 +1,148 @@ +use chrono::{DateTime, Utc}; +use optfield::optfield; + +use database::generated::entities::{upstream, upstream_target}; +use uuid::Uuid; + +use crate::{ + services::nginx::{ + builder::{INDENT_SIZE, NginxConfigProvider}, + info::upstream_target as upstream_target_info, + traits::indentable::Indentable, + }, + set_if_some, +}; + +#[optfield(pub UpdateUpstreamInfo)] +#[derive(Clone)] +pub struct UpstreamInfo { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_targets: Vec, +} + +pub struct UpstreamCreateInfo { + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + // + pub upstream_targets: Vec, +} + +impl NginxConfigProvider for UpstreamInfo { + fn to_nginx_config(&self, indent: Option) -> String { + let targets_config: Vec = self + .upstream_targets + .iter() + .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) + .collect(); + + format!( + "upstream {} {{\n{}\n}}", + self.name, + targets_config.join("\n".indent(indent.unwrap_or(0) + INDENT_SIZE).as_str()) + ) + .indent(indent.unwrap_or(0)) + } +} + +impl From for upstream::ActiveModel { + fn from(val: UpstreamCreateInfo) -> Self { + upstream::ActiveModel { + id: sea_orm::ActiveValue::Set(Uuid::new_v4()), + name: sea_orm::ActiveValue::Set(val.name), + protocol: sea_orm::ActiveValue::Set(val.protocol), + algorithm: sea_orm::ActiveValue::Set(val.algorithm), + sticky_session: sea_orm::ActiveValue::Set(val.sticky_session), + created_by: sea_orm::ActiveValue::Set(val.created_by), + created_at: sea_orm::ActiveValue::Set(chrono::Utc::now()), + updated_at: sea_orm::ActiveValue::Set(chrono::Utc::now()), + } + } +} + +impl From for UpstreamInfo { + fn from(model: upstream::Model) -> Self { + Self { + id: model.id, + name: model.name, + protocol: model.protocol, + algorithm: model.algorithm, + sticky_session: model.sticky_session, + created_by: model.created_by, + created_at: model.created_at, + updated_at: model.updated_at, + upstream_targets: Vec::new(), + } + } +} + +impl From<(upstream::Model, Vec)> for UpstreamInfo { + fn from(data: (upstream::Model, Vec)) -> Self { + let (upstream_model, upstream_target_models) = data; + + Self { + id: upstream_model.id, + name: upstream_model.name, + protocol: upstream_model.protocol, + algorithm: upstream_model.algorithm, + sticky_session: upstream_model.sticky_session, + created_by: upstream_model.created_by, + created_at: upstream_model.created_at, + updated_at: upstream_model.updated_at, + upstream_targets: upstream_target_models + .into_iter() + .map(upstream_target_info::UpstreamTargetInfo::from) + .collect(), + } + } +} + +impl From for (upstream::ActiveModel, Vec) { + fn from(val: UpstreamInfo) -> Self { + ( + upstream::ActiveModel { + id: sea_orm::ActiveValue::Set(val.id), + name: sea_orm::ActiveValue::Set(val.name), + protocol: sea_orm::ActiveValue::Set(val.protocol), + algorithm: sea_orm::ActiveValue::Set(val.algorithm), + sticky_session: sea_orm::ActiveValue::Set(val.sticky_session), + created_by: sea_orm::ActiveValue::Set(val.created_by), + created_at: sea_orm::ActiveValue::Set(val.created_at), + updated_at: sea_orm::ActiveValue::Set(val.updated_at), + }, + val.upstream_targets + .into_iter() + .map(|target| target.into()) + .collect(), + ) + } +} + +impl UpdateUpstreamInfo { + pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel { + upstream::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(current_model.id), + name: set_if_some!(self.name), + protocol: set_if_some!(self.protocol), + algorithm: set_if_some!(self.algorithm), + sticky_session: set_if_some!(self.sticky_session), + created_by: set_if_some!(if self.created_by.is_some() { + Some(self.created_by) + } else { + None + }), + created_at: set_if_some!(self.created_at), + updated_at: set_if_some!(self.updated_at), + } + } +} diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs new file mode 100644 index 0000000..93585f5 --- /dev/null +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -0,0 +1,118 @@ +use chrono::{DateTime, Utc}; +use optfield::optfield; + +use sea_orm::ActiveValue::{Set, Unchanged}; +use uuid::Uuid; + +use database::generated::entities::upstream_target; + +use crate::{ + services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable}, + set_if_some, +}; + +#[optfield(pub UpdateUpstreamTargetInfo)] +#[derive(Clone)] +pub struct UpstreamTargetInfo { + pub id: uuid::Uuid, + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_id: uuid::Uuid, +} + +pub struct UpstreamTargetCreateInfo { + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, + // + pub upstream_id: uuid::Uuid, +} + +impl From for UpstreamTargetInfo { + fn from(model: upstream_target::Model) -> Self { + Self { + id: model.id, + target_host: model.target_host, + target_port: model.target_port, + weight: model.weight, + is_backup: model.is_backup, + enabled: model.enabled, + created_at: model.created_at, + updated_at: model.updated_at, + upstream_id: model.upstream_id, + } + } +} + +impl From for upstream_target::ActiveModel { + fn from(val: UpstreamTargetInfo) -> Self { + upstream_target::ActiveModel { + id: Set(val.id), + target_host: Set(val.target_host), + target_port: Set(val.target_port), + weight: Set(val.weight), + is_backup: Set(val.is_backup), + enabled: Set(val.enabled), + created_at: Set(val.created_at), + updated_at: Set(val.updated_at), + upstream_id: Set(val.upstream_id), + } + } +} + +impl From for upstream_target::ActiveModel { + fn from(val: UpstreamTargetCreateInfo) -> Self { + upstream_target::ActiveModel { + id: Set(Uuid::new_v4()), + target_host: Set(val.target_host), + target_port: Set(val.target_port), + weight: Set(val.weight), + is_backup: Set(val.is_backup), + enabled: Set(val.enabled), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + upstream_id: Set(val.upstream_id), + } + } +} + +impl NginxConfigProvider for UpstreamTargetInfo { + fn to_nginx_config(&self, indent: Option) -> String { + format!( + "{}:{} weight={}{}{}", + self.target_host, + self.target_port, + self.weight, + if self.is_backup { " backup" } else { "" }, + if !self.enabled { " down" } else { "" }, + ) + .indent(indent.unwrap_or(0)) + } +} + +impl UpdateUpstreamTargetInfo { + pub fn apply_to_model( + self, + current_model: upstream_target::Model, + ) -> upstream_target::ActiveModel { + upstream_target::ActiveModel { + id: Unchanged(current_model.id), + target_host: set_if_some!(self.target_host), + target_port: set_if_some!(self.target_port), + weight: set_if_some!(self.weight), + is_backup: set_if_some!(self.is_backup), + enabled: set_if_some!(self.enabled), + created_at: set_if_some!(self.created_at), + updated_at: set_if_some!(self.updated_at), + upstream_id: set_if_some!(self.upstream_id), + } + } +} diff --git a/apps/api/src/services/nginx/traits.rs b/apps/api/src/services/nginx/traits.rs new file mode 100644 index 0000000..66ccbfd --- /dev/null +++ b/apps/api/src/services/nginx/traits.rs @@ -0,0 +1 @@ +pub mod indentable; diff --git a/apps/api/src/services/nginx/traits/indentable.rs b/apps/api/src/services/nginx/traits/indentable.rs new file mode 100644 index 0000000..d8113b5 --- /dev/null +++ b/apps/api/src/services/nginx/traits/indentable.rs @@ -0,0 +1,31 @@ +pub trait Indentable { + fn indent(&self, spaces: T) -> String; +} + +impl Indentable for &str { + fn indent(&self, spaces: usize) -> String { + let indent_str = " ".repeat(spaces); + self.lines() + .map(|line| format!("{}{}", indent_str, line)) + .collect::>() + .join("\n") + } +} + +impl Indentable> for String { + fn indent(&self, spaces: Option) -> String { + self.as_str().indent(spaces.unwrap_or(0)) + } +} + +impl Indentable for String { + fn indent(&self, spaces: usize) -> String { + self.as_str().indent(spaces) + } +} + +impl Indentable> for &str { + fn indent(&self, spaces: Option) -> String { + self.indent(spaces.unwrap_or(0)) + } +} diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs new file mode 100644 index 0000000..335be4f --- /dev/null +++ b/apps/api/src/services/nginx/upstream.rs @@ -0,0 +1,226 @@ +use std::{option, sync::Arc}; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, + ModelTrait, QueryFilter, QuerySelect, +}; + +use database::generated::entities::{upstream, upstream_target}; + +use crate::{ + errors::service_error::ServiceError, + helpers::database::PaginationFilter, + services::nginx::info::{ + upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo}, + upstream_target::{UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo}, + }, + with_conn, +}; + +pub struct UpstreamService { + connection: Arc, +} + +#[derive(Default)] +pub struct GetUpstreamOptions { + pub include_targets: bool, +} + +impl UpstreamService { + pub fn new(connection: Arc) -> Self { + Self { connection } + } + // + // + pub async fn create_upstream( + &self, + create_info: UpstreamCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let model: upstream::ActiveModel = create_info.into(); + let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + Ok(r.into()) + } + + pub async fn get_upstream( + &self, + upstream_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let concrete_options = options.unwrap_or_default(); + let info: UpstreamInfo = if concrete_options.include_targets { + let (up_model, targets) = with_conn!(&*self.connection, tx, conn, { + let up = upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))?; + let targets = upstream_target::Entity::find() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .all(*conn) + .await?; + (up, targets) + }); + (up_model, targets).into() + } else { + with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))? + }) + .into() + }; + Ok(info) + } + + pub async fn get_upstreams( + &self, + pagination: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + let find_query = upstream::Entity::find(); + let find_query = if let Some(pagination) = pagination { + let (offset, limit) = pagination.get_offset_limit(); + find_query.offset(offset).limit(limit) + } else { + find_query + }; + find_query.all(*conn).await? + }); + + Ok(r.into_iter().map(|m| m.into()).collect()) + } + + pub async fn update_upstream( + &self, + id: uuid::Uuid, + upstream: UpdateUpstreamInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + id + )))? + }); + let active_model = upstream.apply_to_model(current_model); + + let r = active_model.update(&*self.connection).await?; + Ok(r.into()) + } + + pub async fn delete_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } + + // + // + pub async fn create_upstream_target( + &self, + create_info: UpstreamTargetCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let model: upstream_target::ActiveModel = create_info.into(); + let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + Ok(r.into()) + } + + pub async fn get_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let r = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))? + }); + Ok(r.into()) + } + + pub async fn get_upstream_targets_by_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .all(*conn) + .await? + }); + Ok(r.into_iter().map(|m| m.into()).collect()) + } + + pub async fn update_upstream_target( + &self, + id: uuid::Uuid, + target: UpdateUpstreamTargetInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + id + )))? + }); + let active_model = target.apply_to_model(current_model); + + let r = active_model.update(&*self.connection).await?; + Ok(r.into()) + } + + pub async fn delete_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } +}