From a896438477d7883b4c8cfdd03eb3d88bac15c5ff Mon Sep 17 00:00:00 2001 From: Guillaume Leroy Date: Mon, 5 Aug 2024 17:28:41 +0200 Subject: [PATCH] refactor: sort code --- src/api.rs | 282 ++++++++++++++++++-------------- src/cmd/default.rs | 4 + src/cmd/mod.rs | 8 + src/deploy/helm.rs | 8 + src/deploy/mod.rs | 8 + src/domain.rs | 39 +---- src/helm/{cli.rs => default.rs} | 24 ++- src/helm/mod.rs | 10 +- src/jwt/default.rs | 10 ++ src/jwt/mod.rs | 11 ++ src/kube/{api.rs => default.rs} | 30 +++- src/kube/mod.rs | 16 +- src/mail/default.rs | 10 ++ src/mail/mod.rs | 8 + src/main.rs | 254 ++++++++++++++-------------- src/op.rs | 122 +++++++------- src/pwd/bcrypt.rs | 4 + src/pwd/mod.rs | 8 + 18 files changed, 505 insertions(+), 351 deletions(-) rename src/helm/{cli.rs => default.rs} (86%) rename src/kube/{api.rs => default.rs} (97%) diff --git a/src/api.rs b/src/api.rs index cb2a541..d8be1bb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -42,42 +42,25 @@ use crate::{ CookieArgs, SignalListener, CARGO_PKG_NAME, }; +// Paths + pub const PATH_JOIN: &str = "/join"; -const COOKIE_NAME_JWT: &str = "simpaas-jwt"; +// Cookies + +const COOKIE_JWT: &str = "simpaas-jwt"; + +// Security schemes const SECURITY_SCHEME_BEARER: &str = "bearerAuth"; const SECURITY_SCHEME_COOKIE: &str = "cookieAuth"; -pub struct ApiContext { - pub cookie: CookieArgs, - pub jwt_encoder: J, - pub kube: K, - pub pwd_encoder: P, -} - -pub async fn start_api< - J: JwtEncoder + 'static, - K: KubeClient + 'static, - P: PasswordEncoder + 'static, ->( - addr: SocketAddr, - root_path: &str, - ctx: ApiContext, -) -> anyhow::Result<()> { - let mut sig = SignalListener::new()?; - debug!("binding tcp listener"); - let tcp = TcpListener::bind(addr).await?; - info!("server started"); - axum::serve(tcp, create_router(root_path, ctx)) - .with_graceful_shutdown(async move { sig.recv().await }) - .await?; - info!("server stopped"); - Ok(()) -} +// Types type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] enum Error { #[error("permission denied")] @@ -155,6 +138,17 @@ impl OperationOutput for Error { } } +// Context + +pub struct ApiContext { + pub cookie: CookieArgs, + pub jwt_encoder: J, + pub kube: K, + pub pwd_encoder: P, +} + +// Query params + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize, Validate)] #[serde(rename_all = "camelCase")] struct AppFilterQuery { @@ -163,6 +157,8 @@ struct AppFilterQuery { name: String, } +// Requests + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize, Validate)] #[serde(rename_all = "camelCase")] struct CreateAppRequest { @@ -218,6 +214,8 @@ struct UserPasswordCredentialsRequest { user: String, } +// Responses + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)] #[serde(rename_all = "camelCase")] struct JwtResponse { @@ -251,6 +249,130 @@ struct ResourceAlreadyExistsItem { value: String, } +// Fns + +pub async fn start_api< + J: JwtEncoder + 'static, + K: KubeClient + 'static, + P: PasswordEncoder + 'static, +>( + addr: SocketAddr, + root_path: &str, + ctx: ApiContext, +) -> anyhow::Result<()> { + let mut sig = SignalListener::new()?; + debug!("binding tcp listener"); + let tcp = TcpListener::bind(addr).await?; + info!("server started"); + axum::serve(tcp, create_router(root_path, ctx)) + .with_graceful_shutdown(async move { sig.recv().await }) + .await?; + info!("server stopped"); + Ok(()) +} + +fn api_doc(api: TransformOpenApi) -> TransformOpenApi { + api.title("SimPaaS API") + .summary(env!("CARGO_PKG_DESCRIPTION")) + .security_scheme( + SECURITY_SCHEME_BEARER, + SecurityScheme::Http { + bearer_format: Some("Bearer ".into()), + description: None, + extensions: Default::default(), + scheme: "Bearer".into(), + }, + ) + .security_scheme( + SECURITY_SCHEME_COOKIE, + SecurityScheme::ApiKey { + description: None, + extensions: Default::default(), + location: ApiKeyLocation::Cookie, + name: COOKIE_JWT.into(), + }, + ) +} + +fn auth_response( + username: &str, + user: &UserSpec, + jar: CookieJar, + ctx: &ApiContext, +) -> Result<(StatusCode, CookieJar, Json)> { + let jwt = ctx + .jwt_encoder + .encode(username, user) + .map_err(Error::JwtEncoding)?; + let cookie = Cookie::build((COOKIE_JWT, jwt.token.clone())) + .domain(ctx.cookie.domain.clone()) + .path("/") + .http_only(!ctx.cookie.http_only_disabled) + .secure(!ctx.cookie.secure_disabled) + .expires(jwt.expiration) + .max_age(jwt.validity); + Ok(( + StatusCode::OK, + jar.add(cookie), + Json(JwtResponse { jwt: jwt.token }), + )) +} + +#[instrument(skip(auth_header, jar, encoder, kube))] +async fn authenticated_user( + auth_header: Option>>, + jar: &CookieJar, + encoder: &J, + kube: &K, +) -> Result<(String, User)> { + let jwt = auth_header + .as_ref() + .map(|header| header.0.token()) + .or_else(|| { + debug!( + "request doesn't contain header `{}`, trying cookie", + header::AUTHORIZATION + ); + jar.get(COOKIE_JWT).map(|cookie| cookie.value()) + }) + .ok_or_else(|| { + debug!("no cookie {COOKIE_JWT}"); + Error::Unauthorized + })?; + let name = encoder.decode(jwt).map_err(Error::JwtDecoding)?; + let user = kube.get_user(&name).await?.ok_or_else(|| { + debug!("user doesn't exist"); + Error::Unauthorized + })?; + Ok((name, user)) +} + +async fn check_permission(user: &User, action: Action<'_>, kube: &K) -> Result { + if kube.user_has_permission(user, action).await? { + Ok(()) + } else { + debug!("user doesn't have required permission"); + Err(Error::Forbidden) + } +} + +async fn ensure_domains_are_free(name: &str, svcs: &[Service], kube: &K) -> Result { + let usages = kube.domain_usages(name, svcs).await?; + if usages.is_empty() { + Ok(()) + } else { + let items = usages + .into_iter() + .map(|usage| ResourceAlreadyExistsItem { + field: "domain".into(), + source: usage.app, + value: usage.domain, + }) + .collect(); + Err(Error::ResourceAlreadyExists(items)) + } +} + fn create_router( root_path: &str, ctx: ApiContext, @@ -323,28 +445,7 @@ fn create_router TransformOpenApi { - api.title("SimPaaS API") - .summary(env!("CARGO_PKG_DESCRIPTION")) - .security_scheme( - SECURITY_SCHEME_BEARER, - SecurityScheme::Http { - bearer_format: Some("Bearer ".into()), - description: None, - extensions: Default::default(), - scheme: "Bearer".into(), - }, - ) - .security_scheme( - SECURITY_SCHEME_COOKIE, - SecurityScheme::ApiKey { - description: None, - extensions: Default::default(), - location: ApiKeyLocation::Cookie, - name: COOKIE_NAME_JWT.into(), - }, - ) -} +// Handlers #[instrument(skip(jar, ctx, req), fields(auth.name = req.user))] async fn authenticate_with_password( @@ -707,84 +808,7 @@ fn update_app_doc(op: TransformOperation) -> TransformOperation { .response_with::<422, (), _>(unprocessable_entity_doc) } -#[instrument(skip(auth_header, jar, encoder, kube))] -async fn authenticated_user( - auth_header: Option>>, - jar: &CookieJar, - encoder: &J, - kube: &K, -) -> Result<(String, User)> { - let jwt = auth_header - .as_ref() - .map(|header| header.0.token()) - .or_else(|| { - debug!( - "request doesn't contain header `{}`, trying cookie", - header::AUTHORIZATION - ); - jar.get(COOKIE_NAME_JWT).map(|cookie| cookie.value()) - }) - .ok_or_else(|| { - debug!("no cookie {COOKIE_NAME_JWT}"); - Error::Unauthorized - })?; - let name = encoder.decode(jwt).map_err(Error::JwtDecoding)?; - let user = kube.get_user(&name).await?.ok_or_else(|| { - debug!("user doesn't exist"); - Error::Unauthorized - })?; - Ok((name, user)) -} - -async fn check_permission(user: &User, action: Action<'_>, kube: &K) -> Result { - if kube.user_has_permission(user, action).await? { - Ok(()) - } else { - debug!("user doesn't have required permission"); - Err(Error::Forbidden) - } -} - -async fn ensure_domains_are_free(name: &str, svcs: &[Service], kube: &K) -> Result { - let usages = kube.domain_usages(name, svcs).await?; - if usages.is_empty() { - Ok(()) - } else { - let items = usages - .into_iter() - .map(|usage| ResourceAlreadyExistsItem { - field: "domain".into(), - source: usage.app, - value: usage.domain, - }) - .collect(); - Err(Error::ResourceAlreadyExists(items)) - } -} - -fn auth_response( - username: &str, - user: &UserSpec, - jar: CookieJar, - ctx: &ApiContext, -) -> Result<(StatusCode, CookieJar, Json)> { - let jwt = ctx - .jwt_encoder - .encode(username, user) - .map_err(Error::JwtEncoding)?; - let cookie = Cookie::build((COOKIE_NAME_JWT, jwt.token.clone())) - .domain(ctx.cookie.domain.clone()) - .path("/") - .http_only(!ctx.cookie.http_only_disabled) - .secure(!ctx.cookie.secure_disabled) - .expires(jwt.expiration) - .max_age(jwt.validity); - Ok(( - StatusCode::OK, - jar.add(cookie), - Json(JwtResponse { jwt: jwt.token }), - )) -} +// Docs fn param_app_name_doc(op: TransformParameter) -> TransformParameter { op.description("Name of the app.") @@ -825,10 +849,14 @@ fn unprocessable_entity_doc(op: TransformResponse) -> TransformResponse op.description("Malformed request.") } +// Defaults + fn default_filter() -> String { r".*".into() } +// AppFilter + impl TryFrom for AppFilter { type Error = Error; diff --git a/src/cmd/default.rs b/src/cmd/default.rs index b3ab09f..eb1e58a 100644 --- a/src/cmd/default.rs +++ b/src/cmd/default.rs @@ -5,6 +5,8 @@ use tracing::{debug, error, instrument, Level}; use super::{CommandRunner, Error, Result}; +// Macros + macro_rules! log_output { ($lvl:ident, $cmd:expr, $output:expr) => {{ let output = String::from_utf8_lossy(&$output); @@ -14,6 +16,8 @@ macro_rules! log_output { }}; } +// DefaultCommandRunner + pub struct DefaultCommandRunner; impl CommandRunner for DefaultCommandRunner { diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 7f87714..874a091 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -2,10 +2,16 @@ use std::{ffi::OsStr, process::Output}; use futures::Future; +// Mods + pub mod default; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("command failed")] @@ -18,6 +24,8 @@ pub enum Error { ), } +// Traits + pub trait CommandRunner: Send + Sync { fn run + Send + Sync>( &self, diff --git a/src/deploy/helm.rs b/src/deploy/helm.rs index 7135391..999a2fd 100644 --- a/src/deploy/helm.rs +++ b/src/deploy/helm.rs @@ -9,6 +9,8 @@ use crate::{domain::App, helm::HelmClient, kube::KubeClient}; use super::{Deployer, Result}; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] @@ -37,6 +39,8 @@ pub enum Error { ), } +// Data structs + #[derive(clap::Args, Clone, Debug, Default, Eq, PartialEq)] pub struct HelmDeployerArgs { #[arg( @@ -48,6 +52,8 @@ pub struct HelmDeployerArgs { pub values_filepath: Option, } +// HelmDeployer + pub struct HelmDeployer { args: HelmDeployerArgs, helm: H, @@ -94,6 +100,8 @@ impl Deployer for HelmDeployer { } } +// super::Error + impl From for super::Error { fn from(err: Error) -> Self { Self(Box::new(err)) diff --git a/src/deploy/mod.rs b/src/deploy/mod.rs index 26db1ad..42cd520 100644 --- a/src/deploy/mod.rs +++ b/src/deploy/mod.rs @@ -2,14 +2,22 @@ use futures::Future; use crate::{domain::App, kube::KubeClient}; +// Mods + pub mod helm; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] #[error("deployer error: {0}")] pub struct Error(#[source] pub Box); +// Traits + pub trait Deployer: Send + Sync { fn deploy( &self, diff --git a/src/domain.rs b/src/domain.rs index d3a6281..d88bc74 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{BTreeMap, BTreeSet, HashSet}, - fmt::{Display, Formatter}, -}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use kube::CustomResource; use regex::Regex; @@ -11,11 +8,7 @@ use serde_json::{Map, Value}; use serde_trim::string_trim; use validator::Validate; -const PERM_CREATE_APP: &str = "createApp"; -const PERM_DELETE_APP: &str = "deleteApp"; -const PERM_INVITE_USERS: &str = "inviteUsers"; -const PERM_READ_APP: &str = "readApp"; -const PERM_UPDATE_APP: &str = "updateApp"; +// Errors #[derive(Debug, thiserror::Error)] #[error("regex error: {0}")] @@ -25,6 +18,8 @@ pub struct PermissionError( pub regex::Error, ); +// Specs + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Action<'a> { CreateApp, @@ -34,18 +29,6 @@ pub enum Action<'a> { UpdateApp(&'a str), } -impl Display for Action<'_> { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - match self { - Self::CreateApp => write!(f, "{PERM_CREATE_APP}"), - Self::DeleteApp(pattern) => write!(f, "{PERM_DELETE_APP}(`{pattern}`)"), - Self::InviteUsers => write!(f, "{PERM_INVITE_USERS}"), - Self::ReadApp(pattern) => write!(f, "{PERM_READ_APP}(`{pattern}`)"), - Self::UpdateApp(pattern) => write!(f, "{PERM_UPDATE_APP}(`{pattern}`)"), - } - } -} - #[derive(Clone, CustomResource, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)] #[kube( group = "simpaas.gleroy.dev", @@ -164,18 +147,6 @@ impl Permission { } } -impl Display for Permission { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - match self { - Self::CreateApp {} => write!(f, "{PERM_CREATE_APP}"), - Self::DeleteApp { name } => write!(f, "{PERM_DELETE_APP}(`{name}`)"), - Self::InviteUsers {} => write!(f, "{PERM_INVITE_USERS}"), - Self::ReadApp { name } => write!(f, "{PERM_READ_APP}(`{name}`)"), - Self::UpdateApp { name } => write!(f, "{PERM_UPDATE_APP}(`{name}`)"), - } - } -} - #[derive(Clone, CustomResource, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)] #[kube( group = "simpaas.gleroy.dev", @@ -279,6 +250,8 @@ pub struct UserSpec { pub roles: BTreeSet, } +// Defaults + fn default_perm_pattern() -> String { r".*".into() } diff --git a/src/helm/cli.rs b/src/helm/default.rs similarity index 86% rename from src/helm/cli.rs rename to src/helm/default.rs index b5b4865..10b426a 100644 --- a/src/helm/cli.rs +++ b/src/helm/default.rs @@ -6,9 +6,13 @@ use crate::{cmd::CommandRunner, domain::App}; use super::{HelmClient, Result}; +// Defaults + const DEFAULT_BIN: &str = "helm"; const DEFAULT_CHART_PATH: &str = "charts/simpaas-app"; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] @@ -21,8 +25,10 @@ pub enum Error { InvalidUnicode, } +// Data structs + #[derive(clap::Args, Clone, Debug, Eq, PartialEq)] -pub struct CliHelmClientArgs { +pub struct DefaultHelmClientArgs { #[arg( long = "helm-bin", env = "HELM_BIN", @@ -40,7 +46,7 @@ pub struct CliHelmClientArgs { pub chart_path: PathBuf, } -impl Default for CliHelmClientArgs { +impl Default for DefaultHelmClientArgs { fn default() -> Self { Self { bin: DEFAULT_BIN.into(), @@ -49,18 +55,20 @@ impl Default for CliHelmClientArgs { } } -pub struct CliHelmClient { - args: CliHelmClientArgs, +// DefaultHelmClient + +pub struct DefaultHelmClient { + args: DefaultHelmClientArgs, runner: R, } -impl CliHelmClient { - pub fn new(args: CliHelmClientArgs, runner: R) -> Self { +impl DefaultHelmClient { + pub fn new(args: DefaultHelmClientArgs, runner: R) -> Self { Self { args, runner } } } -impl HelmClient for CliHelmClient { +impl HelmClient for DefaultHelmClient { #[instrument("helm_uninstall", skip(self, name, app), fields(app.name = name, app.namespace = app.spec.namespace))] async fn uninstall(&self, name: &str, app: &App) -> Result { debug!("running helm uninstall"); @@ -102,6 +110,8 @@ impl HelmClient for CliHelmClient { } } +// super::Error + impl From for super::Error { fn from(err: Error) -> Self { Self(Box::new(err)) diff --git a/src/helm/mod.rs b/src/helm/mod.rs index 5ab28fd..b3aaf81 100644 --- a/src/helm/mod.rs +++ b/src/helm/mod.rs @@ -4,14 +4,22 @@ use futures::Future; use crate::domain::App; -pub mod cli; +// Mods + +pub mod default; + +// Types pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] #[error("helm error: {0}")] pub struct Error(#[source] pub Box); +// Traits + pub trait HelmClient: Send + Sync { fn uninstall(&self, name: &str, app: &App) -> impl Future + Send; diff --git a/src/jwt/default.rs b/src/jwt/default.rs index 27e3dfe..2cd766b 100644 --- a/src/jwt/default.rs +++ b/src/jwt/default.rs @@ -16,9 +16,13 @@ use crate::domain::UserSpec; use super::{Jwt, JwtEncoder, Result}; +// Defaults + const DEFAULT_PRIVKEY: &str = "etc/privkey.pem"; const DEFAULT_VALIDITY: u32 = 24 * 60 * 60; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] @@ -37,6 +41,8 @@ pub enum Error { ), } +// Data structs + #[derive(clap::Args, Clone, Debug, Eq, PartialEq)] pub struct DefaultJwtEncoderArgs { #[arg( @@ -66,6 +72,8 @@ impl Default for DefaultJwtEncoderArgs { } } +// DefaultJwtEncoder + pub struct DefaultJwtEncoder { privkey: PKeyWithDigest, pubkey: PKeyWithDigest, @@ -143,6 +151,8 @@ impl JwtEncoder for DefaultJwtEncoder { } } +// super::Error + impl From for super::Error { fn from(err: Error) -> Self { Self(Box::new(err)) diff --git a/src/jwt/mod.rs b/src/jwt/mod.rs index 6e8f0cc..c46006e 100644 --- a/src/jwt/mod.rs +++ b/src/jwt/mod.rs @@ -2,20 +2,31 @@ use time::{Duration, OffsetDateTime}; use crate::domain::UserSpec; +// Mods + pub mod default; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] #[error("jwt error: {0}")] pub struct Error(#[source] pub Box); +// Data structs + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Jwt { pub expiration: OffsetDateTime, pub token: String, pub validity: Duration, } +// Traits + pub trait JwtEncoder: Send + Sync { fn decode(&self, jwt: &str) -> Result; diff --git a/src/kube/api.rs b/src/kube/default.rs similarity index 97% rename from src/kube/api.rs rename to src/kube/default.rs index 1d2bddb..0128219 100644 --- a/src/kube/api.rs +++ b/src/kube/default.rs @@ -26,6 +26,8 @@ use super::{ ServicePod, ServicePodStatus, LABEL_APP, LABEL_SERVICE, }; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] @@ -50,15 +52,17 @@ pub enum Error { ), } -pub struct ApiKubeClient(Client); +// DefaultKubeClient + +pub struct DefaultKubeClient(Client); -impl ApiKubeClient { +impl DefaultKubeClient { pub fn new(client: Client) -> Self { Self(client) } } -impl KubeClient for ApiKubeClient { +impl KubeClient for DefaultKubeClient { #[instrument(skip(self, name), fields(app.name = name))] async fn delete_app(&self, name: &str) -> Result { let api: Api = Api::default_namespaced(self.0.clone()); @@ -275,7 +279,7 @@ impl KubeClient for ApiKubeClient { Ok(()) } - #[instrument(skip(self, user, action), fields(%action))] + #[instrument(skip(self, user))] async fn user_has_permission(&self, user: &User, action: Action<'_>) -> Result { for role in &user.spec.roles { let role = self.get_role(role).await?; @@ -306,12 +310,14 @@ impl KubeClient for ApiKubeClient { } } -pub struct ApiKubeEventPublisher { +// DefaultKubeEventPublisher + +pub struct DefaultKubeEventPublisher { client: Client, reporter: Reporter, } -impl ApiKubeEventPublisher { +impl DefaultKubeEventPublisher { pub fn new(client: Client, instance: Option) -> Self { Self { client, @@ -329,7 +335,7 @@ impl ApiKubeEventPublisher { } } -impl KubeEventPublisher for ApiKubeEventPublisher { +impl KubeEventPublisher for DefaultKubeEventPublisher { #[instrument(skip(self, app, event))] async fn publish_app_event(&self, app: &App, event: KubeEvent) { debug!("publishing app event"); @@ -353,6 +359,8 @@ impl KubeEventPublisher for ApiKubeEventPublisher { } } +// super::Error + impl From for super::Error { fn from(err: Error) -> Self { Self(Box::new(err)) @@ -377,6 +385,8 @@ impl From for super::Error { } } +// ::kube::runtime::events::Event + impl From for ::kube::runtime::events::Event { fn from(event: KubeEvent) -> Self { Self { @@ -389,6 +399,8 @@ impl From for ::kube::runtime::events::Event { } } +// EventType + impl From for EventType { fn from(kind: KubeEventKind) -> Self { match kind { @@ -398,6 +410,8 @@ impl From for EventType { } } +// ServicePod + impl TryFrom for ServicePod { type Error = Error; @@ -415,6 +429,8 @@ impl TryFrom for ServicePod { } } +// ServicePodStatus + impl From for ServicePodStatus { fn from(status: PodStatus) -> Self { match status.phase.as_deref() { diff --git a/src/kube/mod.rs b/src/kube/mod.rs index c67b68c..28d8949 100644 --- a/src/kube/mod.rs +++ b/src/kube/mod.rs @@ -7,19 +7,31 @@ use crate::domain::{ Action, App, AppStatus, Invitation, InvitationStatus, Permission, Role, Service, User, }; -pub mod api; +// Mods + +pub mod default; + +// Finalizers pub const FINALIZER: &str = "simpaas.gleroy.dev/finalizer"; +// Labels + pub const LABEL_APP: &str = "simpaas.gleroy.dev/app"; pub const LABEL_SERVICE: &str = "simpaas.gleroy.dev/service"; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] #[error("kubernetes error: {0}")] pub struct Error(#[source] pub Box); +// Data structs + #[derive(Clone, Debug)] pub struct AppFilter { pub name: Regex, @@ -59,6 +71,8 @@ pub enum ServicePodStatus { Stopped, } +// Traits + pub trait KubeClient: Send + Sync { fn delete_app(&self, name: &str) -> impl Future + Send; diff --git a/src/mail/default.rs b/src/mail/default.rs index 009d7fe..aa26fc9 100644 --- a/src/mail/default.rs +++ b/src/mail/default.rs @@ -6,10 +6,14 @@ use crate::{api::PATH_JOIN, domain::Invitation}; use super::{MailSender, Result}; +// Defaults + const DEFAULT_FROM: &str = "noreply@127.0.0.1"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 25; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("liquid error: {0}")] @@ -26,6 +30,8 @@ pub enum Error { ), } +// Data structs + #[derive(clap::Args, Clone, Debug, Eq, PartialEq)] pub struct DefaultMailSenderArgs { #[arg( @@ -96,6 +102,8 @@ impl Default for DefaultMailSenderArgs { } } +// DefaultMailSender + pub struct DefaultMailSender { args: DefaultMailSenderArgs, invit_tpl: Template, @@ -154,6 +162,8 @@ impl MailSender for DefaultMailSender { } } +// super::Error + impl From for super::Error { fn from(err: Error) -> Self { Self(Box::new(err)) diff --git a/src/mail/mod.rs b/src/mail/mod.rs index 4f9b249..0cef9d2 100644 --- a/src/mail/mod.rs +++ b/src/mail/mod.rs @@ -2,14 +2,22 @@ use futures::Future; use crate::domain::Invitation; +// Mods + pub mod default; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] #[error("mail error: {0}")] pub struct Error(#[source] pub Box); +// Traits + pub trait MailSender: Send + Sync { fn send_invitation( &self, diff --git a/src/main.rs b/src/main.rs index 6074f73..3472f5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,9 @@ use clap::{Parser, Subcommand}; use cmd::default::DefaultCommandRunner; use deploy::helm::{HelmDeployer, HelmDeployerArgs}; use domain::{App, Invitation, Role, User}; -use helm::cli::{CliHelmClient, CliHelmClientArgs}; +use helm::default::{DefaultHelmClient, DefaultHelmClientArgs}; use jwt::default::{DefaultJwtEncoder, DefaultJwtEncoderArgs}; -use kube::api::{ApiKubeClient, ApiKubeEventPublisher}; +use kube::default::{DefaultKubeClient, DefaultKubeEventPublisher}; use mail::default::{DefaultMailSender, DefaultMailSenderArgs}; use op::{start_op, OpContext}; use opentelemetry::KeyValue; @@ -36,6 +36,8 @@ use tracing_subscriber::{ fmt::layer, layer::SubscriberExt, registry, util::SubscriberInitExt, EnvFilter, }; +// Mods + mod api; mod cmd; mod deploy; @@ -47,39 +49,12 @@ mod mail; mod op; mod pwd; -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); - init_tracing(args.obs)?; - match args.cmd { - Command::Api(args) => { - let kube = ::kube::Client::try_default().await?; - let ctx = ApiContext { - cookie: args.cookie, - jwt_encoder: DefaultJwtEncoder::new(args.jwt)?, - kube: ApiKubeClient::new(kube), - pwd_encoder: BcryptPasswordEncoder, - }; - start_api(args.bind_addr, &args.root_path, ctx).await - } - Command::Crd { cmd } => cmd.print(), - Command::Op(args) => { - let kube = ::kube::Client::try_default().await?; - let helm = CliHelmClient::new(args.helm, DefaultCommandRunner); - let ctx = OpContext { - delays: args.delays.into(), - deployer: HelmDeployer::new(args.deployer, helm), - kube: ApiKubeClient::new(kube.clone()), - mail_sender: DefaultMailSender::new(args.mail, args.webapp_url)?, - publisher: ApiKubeEventPublisher::new(kube.clone(), args.instance), - }; - start_op(kube, ctx).await - } - } -} +// Bin const CARGO_PKG_NAME: &str = env!("CARGO_PKG_NAME"); +// Defaults + const DEFAULT_APP_STATUS_DELAY: u64 = 30; const DEFAULT_BIND_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), 8080)); @@ -88,65 +63,7 @@ const DEFAULT_RETRY_DELAY: u64 = 10; const DEFAULT_ROOT_PATH: &str = "/"; const DEFAULT_WEBAPP_URL: &str = "http://localhost:3000"; -#[derive(Clone, Debug, Eq, Parser, PartialEq)] -#[command(version)] -struct Args { - #[command(subcommand)] - cmd: Command, - #[command(flatten)] - obs: ObsArgs, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Subcommand)] -enum CrdCommand { - #[command(about = "Print App CRD")] - App, - #[command(about = "Print Invitation CRD", alias = "invit")] - Invitation, - #[command(about = "Print Role CRD")] - Role, - #[command(about = "Print User CRD")] - User, -} - -impl CrdCommand { - fn print(self) -> anyhow::Result<()> { - let crd = match self { - Self::App => App::crd(), - Self::Invitation => Invitation::crd(), - Self::Role => Role::crd(), - Self::User => User::crd(), - }; - serde_yaml::to_writer(stdout(), &crd)?; - Ok(()) - } -} - -#[derive(clap::Args, Clone, Debug, Eq, PartialEq)] -struct ObsArgs { - #[arg( - long, - env, - default_value = "simpaas=info,warn", - long_help = "Log filter (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)" - )] - log_filter: String, - #[arg(long, env, long_help = "URL to OTEL collector")] - otel_collector_url: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Subcommand)] -enum Command { - #[command(about = "Start API server")] - Api(ApiArgs), - #[command(about = "Print CRD")] - Crd { - #[command(subcommand)] - cmd: CrdCommand, - }, - #[command(about = "Start operator")] - Op(OpArgs), -} +// CLI #[derive(clap::Args, Clone, Debug, Eq, PartialEq)] struct ApiArgs { @@ -176,6 +93,28 @@ impl Default for ApiArgs { } } +#[derive(Clone, Debug, Eq, Parser, PartialEq)] +#[command(version)] +struct Args { + #[command(subcommand)] + cmd: Command, + #[command(flatten)] + obs: ObsArgs, +} + +#[derive(Clone, Debug, Eq, PartialEq, Subcommand)] +enum Command { + #[command(about = "Start API server")] + Api(ApiArgs), + #[command(about = "Print CRD")] + Crd { + #[command(subcommand)] + cmd: CrdCommand, + }, + #[command(about = "Start operator")] + Op(OpArgs), +} + #[derive(clap::Args, Clone, Debug, Eq, PartialEq)] struct CookieArgs { #[arg( @@ -209,37 +148,28 @@ impl Default for CookieArgs { } } -#[derive(clap::Args, Clone, Debug, Eq, PartialEq)] -struct OpArgs { - #[command(flatten)] - delays: DelayArgs, - #[command(flatten)] - deployer: HelmDeployerArgs, - #[command(flatten)] - helm: CliHelmClientArgs, - #[arg(long, env, long_help = "Name of current instance")] - instance: Option, - #[command(flatten)] - mail: DefaultMailSenderArgs, - #[arg( - long, - env, - default_value = DEFAULT_WEBAPP_URL, - long_help = "WebApp URL" - )] - webapp_url: String, +#[derive(Clone, Copy, Debug, Eq, PartialEq, Subcommand)] +enum CrdCommand { + #[command(about = "Print App CRD")] + App, + #[command(about = "Print Invitation CRD", alias = "invit")] + Invitation, + #[command(about = "Print Role CRD")] + Role, + #[command(about = "Print User CRD")] + User, } -impl Default for OpArgs { - fn default() -> Self { - Self { - delays: Default::default(), - deployer: Default::default(), - helm: Default::default(), - instance: None, - mail: DefaultMailSenderArgs::default(), - webapp_url: DEFAULT_WEBAPP_URL.into(), - } +impl CrdCommand { + fn print(self) -> anyhow::Result<()> { + let crd = match self { + Self::App => App::crd(), + Self::Invitation => Invitation::crd(), + Self::Role => Role::crd(), + Self::User => User::crd(), + }; + serde_yaml::to_writer(stdout(), &crd)?; + Ok(()) } } @@ -272,6 +202,55 @@ impl Default for DelayArgs { } } +#[derive(clap::Args, Clone, Debug, Eq, PartialEq)] +struct ObsArgs { + #[arg( + long, + env, + default_value = "simpaas=info,warn", + long_help = "Log filter (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)" + )] + log_filter: String, + #[arg(long, env, long_help = "URL to OTEL collector")] + otel_collector_url: Option, +} + +#[derive(clap::Args, Clone, Debug, Eq, PartialEq)] +struct OpArgs { + #[command(flatten)] + delays: DelayArgs, + #[command(flatten)] + deployer: HelmDeployerArgs, + #[command(flatten)] + helm: DefaultHelmClientArgs, + #[arg(long, env, long_help = "Name of current instance")] + instance: Option, + #[command(flatten)] + mail: DefaultMailSenderArgs, + #[arg( + long, + env, + default_value = DEFAULT_WEBAPP_URL, + long_help = "WebApp URL" + )] + webapp_url: String, +} + +impl Default for OpArgs { + fn default() -> Self { + Self { + delays: Default::default(), + deployer: Default::default(), + helm: Default::default(), + instance: None, + mail: DefaultMailSenderArgs::default(), + webapp_url: DEFAULT_WEBAPP_URL.into(), + } + } +} + +// SignalListener + struct SignalListener { int: Signal, term: Signal, @@ -297,6 +276,41 @@ impl SignalListener { } } +// Main + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + init_tracing(args.obs)?; + match args.cmd { + Command::Api(args) => { + let kube = ::kube::Client::try_default().await?; + let ctx = ApiContext { + cookie: args.cookie, + jwt_encoder: DefaultJwtEncoder::new(args.jwt)?, + kube: DefaultKubeClient::new(kube), + pwd_encoder: BcryptPasswordEncoder, + }; + start_api(args.bind_addr, &args.root_path, ctx).await + } + Command::Crd { cmd } => cmd.print(), + Command::Op(args) => { + let kube = ::kube::Client::try_default().await?; + let helm = DefaultHelmClient::new(args.helm, DefaultCommandRunner); + let ctx = OpContext { + delays: args.delays.into(), + deployer: HelmDeployer::new(args.deployer, helm), + kube: DefaultKubeClient::new(kube.clone()), + mail_sender: DefaultMailSender::new(args.mail, args.webapp_url)?, + publisher: DefaultKubeEventPublisher::new(kube.clone(), args.instance), + }; + start_op(kube, ctx).await + } + } +} + +// Fns + fn init_tracing(args: ObsArgs) -> anyhow::Result<()> { let filter = EnvFilter::builder().parse(args.log_filter)?; let sub = layer().with_writer(stderr); diff --git a/src/op.rs b/src/op.rs index 37817c2..49eb546 100644 --- a/src/op.rs +++ b/src/op.rs @@ -20,8 +20,12 @@ use crate::{ DelayArgs, SignalListener, }; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] @@ -46,6 +50,8 @@ pub enum Error { NoName, } +// Data structs + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Delays { app_status: Duration, @@ -61,6 +67,8 @@ impl From for Delays { } } +// Context + pub struct OpContext { pub delays: Delays, pub deployer: D, @@ -69,6 +77,8 @@ pub struct OpContext( - _res: Arc, - _err: &::kube::Error, - ctx: Arc>, -) -> Action { - Action::requeue(ctx.delays.retry) -} - fn log_controller_error( err: &ControllerError, ) { @@ -158,6 +160,62 @@ fn log_controller_error( + _res: Arc, + _err: &::kube::Error, + ctx: Arc>, +) -> Action { + Action::requeue(ctx.delays.retry) +} + +async fn publishing_event< + E: std::error::Error + Into, + FUT: Future>, + NERR: Fn(&E) -> String, + NOK: Fn(&V) -> String, + P: Fn(KubeEvent) -> PFUT, + PFUT: Future, + V, +>( + fut: FUT, + action: &'static str, + ok_reason: &'static str, + note: String, + ok_note: NOK, + err_note: NERR, + publish: P, +) -> Result { + let event = KubeEvent { + action, + kind: KubeEventKind::Normal, + note, + reason: action, + }; + publish(event).await; + match fut.await { + Ok(val) => { + let event = KubeEvent { + action, + kind: KubeEventKind::Normal, + note: ok_note(&val), + reason: ok_reason, + }; + publish(event).await; + Ok(val) + } + Err(err) => { + let event = KubeEvent { + action, + kind: KubeEventKind::Warn, + note: err_note(&err), + reason: "Failed", + }; + publish(event).await; + Err(err.into()) + } + } +} + async fn reconcile_app( app: Arc, ctx: Arc>, @@ -304,53 +362,7 @@ async fn update_service_statuses( Ok(()) } -async fn publishing_event< - E: std::error::Error + Into, - FUT: Future>, - NERR: Fn(&E) -> String, - NOK: Fn(&V) -> String, - P: Fn(KubeEvent) -> PFUT, - PFUT: Future, - V, ->( - fut: FUT, - action: &'static str, - ok_reason: &'static str, - note: String, - ok_note: NOK, - err_note: NERR, - publish: P, -) -> Result { - let event = KubeEvent { - action, - kind: KubeEventKind::Normal, - note, - reason: action, - }; - publish(event).await; - match fut.await { - Ok(val) => { - let event = KubeEvent { - action, - kind: KubeEventKind::Normal, - note: ok_note(&val), - reason: ok_reason, - }; - publish(event).await; - Ok(val) - } - Err(err) => { - let event = KubeEvent { - action, - kind: KubeEventKind::Warn, - note: err_note(&err), - reason: "Failed", - }; - publish(event).await; - Err(err.into()) - } - } -} +// ::kube::Error impl From for ::kube::Error { fn from(err: Error) -> Self { diff --git a/src/pwd/bcrypt.rs b/src/pwd/bcrypt.rs index 9fc46ba..d6aafae 100644 --- a/src/pwd/bcrypt.rs +++ b/src/pwd/bcrypt.rs @@ -3,6 +3,8 @@ use tracing::{debug, instrument}; use super::{Error, PasswordEncoder, Result}; +// BCryptPasswordEncoder + pub struct BcryptPasswordEncoder; impl PasswordEncoder for BcryptPasswordEncoder { @@ -19,6 +21,8 @@ impl PasswordEncoder for BcryptPasswordEncoder { } } +// Error + impl From for Error { fn from(err: BcryptError) -> Self { Self(Box::new(err)) diff --git a/src/pwd/mod.rs b/src/pwd/mod.rs index 05ca473..7b9a569 100644 --- a/src/pwd/mod.rs +++ b/src/pwd/mod.rs @@ -1,11 +1,19 @@ +// Mods + pub mod bcrypt; +// Types + pub type Result = std::result::Result; +// Errors + #[derive(Debug, thiserror::Error)] #[error("password error: {0}")] pub struct Error(#[source] pub Box); +// Traits + pub trait PasswordEncoder: Send + Sync { fn encode(&self, password: &str) -> Result;