diff --git a/Cargo.lock b/Cargo.lock index 523d1ca..2fdb432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2796,6 +2796,7 @@ dependencies = [ "strum 0.24.1", "strum_macros 0.24.3", "tokio", + "toml", "tower", "tower-test", ] @@ -3241,9 +3242,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ac8060a61f8758a61562f6fb53ba3cbe1ca906f001df2e53cccddcdbee91e7c" +checksum = "55ae70283aba8d2a8b411c695c437fe25b8b5e44e23e780662002fc72fb47a82" dependencies = [ "base64 0.21.2", "bitflags 2.3.3", diff --git a/Cargo.toml b/Cargo.toml index 2d09adf..7a1a9da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ clarity-repl = "1.6.4" clarinet-files = "1.0.0" chainhook-types = "1.0.1" clarinet-deployments = "1.0.1" +toml = "0.5.9" [dev-dependencies] tower-test = "0.4.0" diff --git a/Config.toml b/Config.toml new file mode 100644 index 0000000..3d004fd --- /dev/null +++ b/Config.toml @@ -0,0 +1,2 @@ +allowed_origins = ["*"] +allowed_methods = ["DELETE", "GET", "OPTIONS", "POST", "HEAD"] diff --git a/README.md b/README.md index b3d1a4f..64a4384 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,15 @@ brew install kind You should now be ready to deploy this service to your local Kubernetes cluster! +## Configuration +The `Config.toml` at the root directory of the project can be used to control some settings. This same file can be used to update both the stable and development build. The following settings are supported: + - `allowed_origins` - this setting is an array of strings and is used to set what origins are allowed in cross-origin requests. For example, `allowed_origins = ["*"]` allows any origins to make requests to this service, while `allowed_origins = ["localhost:3002", "dev.platform.so"]` will only allow requests from the two specified hosts. + - `allowed_methods` - this setting is an array of strings that sets what HTTP methods can be made to this server. + ## Deploying the Stable Version -In your terminal, rum +In your terminal, run ``` -kubectl --context kind-kind apply -f ./templates/stacks-devnet-api.template.yaml +./scripts/deploy-api.sh ``` to install the [latest version of this service](https://quay.io/repository/hirosystems/stacks-devnet-api?tab=history) that has been deployed to docker (or, to quay for now). This service should now be fully running on your Kubernetes cluster. See the [usage](#usage) sections for steps on how to use the service. @@ -52,14 +57,9 @@ spec: + imagePullPolicy: Never ``` -If a version of this tool has already been deployed to your local cluster, you'll need to delete the existing pod. You'll need to do this every time you redeploy the service: -``` -kubectl --context kind-kind delete pod stacks-devnet-api --namespace devnet -``` - Finally, run ``` -kubectl --context kind-kind apply -f ./templates/stacks-devnet-api.template.yaml +./scripts/redeploy-api.sh ``` to deploy to your local cluster. diff --git a/scripts/deploy-api.sh b/scripts/deploy-api.sh new file mode 100755 index 0000000..a06491a --- /dev/null +++ b/scripts/deploy-api.sh @@ -0,0 +1,2 @@ +kubectl --context kind-kind create configmap stacks-devnet-api-conf --from-file=./Config.toml --namespace devnet && \ +kubectl --context kind-kind apply -f ./templates/stacks-devnet-api.template.yaml diff --git a/scripts/redeploy-api.sh b/scripts/redeploy-api.sh new file mode 100755 index 0000000..4654338 --- /dev/null +++ b/scripts/redeploy-api.sh @@ -0,0 +1,3 @@ +kubectl --context kind-kind delete configmap stacks-devnet-api-conf --namespace devnet & \ +kubectl --context kind-kind delete pod stacks-devnet-api --namespace devnet && \ +./scripts/deploy-api.sh diff --git a/src/lib.rs b/src/lib.rs index 98fd51a..5f25781 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ mod template_parser; use template_parser::get_yaml_from_resource; pub mod resources; +pub mod responder; pub mod routes; use crate::resources::configmap::StacksDevnetConfigmap; use crate::resources::pod::StacksDevnetPod; diff --git a/src/main.rs b/src/main.rs index e674356..c5e157d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use hiro_system_kit::slog; use hyper::server::conn::AddrStream; use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Method, Request, Response, Server, StatusCode}; +use hyper::{Body, Method, Request, Response, Server}; +use stacks_devnet_api::responder::{Responder, ResponderConfig}; use stacks_devnet_api::routes::{ get_standardized_path_parts, handle_delete_devnet, handle_get_devnet, handle_new_devnet, handle_try_proxy_service, API_PATH, @@ -24,14 +25,27 @@ async fn main() { tracer: false, }; let k8s_manager = StacksDevnetApiK8sManager::default(&ctx).await; + let config_path = if cfg!(debug_assertions) { + "./Config.toml" + } else { + "/etc/config/Config.toml" + }; + let config = ResponderConfig::from_path(config_path); let make_svc = make_service_fn(|conn: &AddrStream| { let k8s_manager = k8s_manager.clone(); let ctx = ctx.clone(); let remote_addr = conn.remote_addr().ip(); + let config = config.clone(); async move { Ok::<_, Infallible>(service_fn(move |req| { - handle_request(remote_addr, req, k8s_manager.clone(), ctx.clone()) + handle_request( + remote_addr, + req, + k8s_manager.clone(), + config.clone(), + ctx.clone(), + ) })) } }); @@ -49,6 +63,7 @@ async fn handle_request( _client_ip: IpAddr, request: Request, k8s_manager: StacksDevnetApiK8sManager, + config: ResponderConfig, ctx: Context, ) -> Result, Infallible> { let uri = request.uri(); @@ -63,29 +78,24 @@ async fn handle_request( ) }); + let responder = Responder::new(config, request.headers().clone()).unwrap(); + if method == &Method::OPTIONS { + return responder.ok(); + } if path == "/api/v1/networks" { return match method { - &Method::POST => handle_new_devnet(request, k8s_manager, ctx).await, - _ => Ok(Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(Body::try_from("network creation must be a POST request").unwrap()) - .unwrap()), + &Method::POST => handle_new_devnet(request, k8s_manager, responder, ctx).await, + _ => responder.err_method_not_allowed("network creation must be a POST request".into()), }; } else if path.starts_with(API_PATH) { let path_parts = get_standardized_path_parts(uri.path()); if path_parts.route != "network" { - return Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::try_from("invalid request path").unwrap()) - .unwrap()); + return responder.err_bad_request("invalid request path".into()); } // the api path must be followed by a network id if path_parts.network.is_none() { - return Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::try_from("no network id provided").unwrap()) - .unwrap()); + return responder.err_bad_request("no network id provided".into()); } let network = path_parts.network.unwrap(); @@ -93,56 +103,50 @@ async fn handle_request( let exists = match k8s_manager.check_namespace_exists(&network).await { Ok(exists) => exists, Err(e) => { - return Ok(Response::builder() - .status(StatusCode::from_u16(e.code).unwrap()) - .body(Body::try_from(e.message).unwrap()) - .unwrap()); + return responder.respond(e.code, e.message); } }; if !exists { let msg = format!("network {} does not exist", &network); ctx.try_log(|logger| slog::warn!(logger, "{}", msg)); - return Ok(Response::builder() - .status(StatusCode::from_u16(404).unwrap()) - .body(Body::try_from(msg).unwrap()) - .unwrap()); + return responder.err_not_found(msg); } // the path only contained the network path and network id, // so it must be a request to DELETE a network or GET network info if path_parts.subroute.is_none() { return match method { - &Method::DELETE => handle_delete_devnet(k8s_manager, &network).await, - &Method::GET => handle_get_devnet(k8s_manager, &network, ctx).await, - _ => Ok(Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(Body::empty()) - .unwrap()), + &Method::DELETE => handle_delete_devnet(k8s_manager, &network, responder).await, + &Method::GET => handle_get_devnet(k8s_manager, &network, responder, ctx).await, + _ => { + responder.err_method_not_allowed("can only GET/DELETE at provided route".into()) + } }; } let subroute = path_parts.subroute.unwrap(); if subroute == "commands" { - return Ok(Response::builder() - .status(StatusCode::NOT_IMPLEMENTED) - .body(Body::empty()) - .unwrap()); + return responder.err_not_implemented("commands route in progress".into()); } else { let remaining_path = path_parts.remainder.unwrap_or(String::new()); - return handle_try_proxy_service(&remaining_path, &subroute, &network, request, &ctx) - .await; + return handle_try_proxy_service( + &remaining_path, + &subroute, + &network, + request, + responder, + &ctx, + ) + .await; } } - Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::try_from("invalid request path").unwrap()) - .unwrap()) + responder.err_bad_request("invalid request path".into()) } #[cfg(test)] mod tests { use super::*; - use hyper::body; + use hyper::{body, StatusCode}; use k8s_openapi::api::core::v1::Namespace; use stacks_devnet_api::{ resources::service::{ @@ -211,9 +215,15 @@ mod tests { for path in invalid_paths { let request_builder = Request::builder().uri(path).method("GET"); let request: Request = request_builder.body(Body::empty()).unwrap(); - let mut response = handle_request(client_ip, request, k8s_manager.clone(), ctx.clone()) - .await - .unwrap(); + let mut response = handle_request( + client_ip, + request, + k8s_manager.clone(), + ResponderConfig::default(), + ctx.clone(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.body_mut(); let bytes = body::to_bytes(body).await.unwrap().to_vec(); @@ -241,9 +251,15 @@ mod tests { let request_builder = Request::builder().uri(path).method("GET"); let request: Request = request_builder.body(Body::empty()).unwrap(); - let mut response = handle_request(client_ip, request, k8s_manager.clone(), ctx) - .await - .unwrap(); + let mut response = handle_request( + client_ip, + request, + k8s_manager.clone(), + ResponderConfig::default(), + ctx, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = response.body_mut(); let bytes = body::to_bytes(body).await.unwrap().to_vec(); @@ -270,9 +286,15 @@ mod tests { let request_builder = Request::builder().uri(path).method("GET"); let request: Request = request_builder.body(Body::empty()).unwrap(); - let mut response = handle_request(client_ip, request, k8s_manager.clone(), ctx) - .await - .unwrap(); + let mut response = handle_request( + client_ip, + request, + k8s_manager.clone(), + ResponderConfig::default(), + ctx, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.body_mut(); let bytes = body::to_bytes(body).await.unwrap().to_vec(); @@ -301,9 +323,15 @@ mod tests { for method in methods { let request_builder = Request::builder().uri(path).method(method); let request: Request = request_builder.body(Body::empty()).unwrap(); - let mut response = handle_request(client_ip, request, k8s_manager.clone(), ctx.clone()) - .await - .unwrap(); + let mut response = handle_request( + client_ip, + request, + k8s_manager.clone(), + ResponderConfig::default(), + ctx.clone(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); let body = response.body_mut(); let bytes = body::to_bytes(body).await.unwrap().to_vec(); @@ -330,9 +358,15 @@ mod tests { let request_builder = Request::builder().uri(path).method("POST"); let request: Request = request_builder.body(Body::empty()).unwrap(); - let mut response = handle_request(client_ip, request, k8s_manager.clone(), ctx) - .await - .unwrap(); + let mut response = handle_request( + client_ip, + request, + k8s_manager.clone(), + ResponderConfig::default(), + ctx, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.body_mut(); let bytes = body::to_bytes(body).await.unwrap().to_vec(); diff --git a/src/responder.rs b/src/responder.rs new file mode 100644 index 0000000..2f90ec6 --- /dev/null +++ b/src/responder.rs @@ -0,0 +1,119 @@ +use std::{ + convert::Infallible, + fs::File, + io::{BufReader, Read}, +}; + +use hyper::{ + header::{ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ORIGIN}, + http::{response::Builder, HeaderValue}, + Body, HeaderMap, Response, StatusCode, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Default)] +pub struct Responder { + allowed_origins: Vec, + allowed_methods: Vec, + headers: HeaderMap, +} + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct ResponderConfig { + allowed_origins: Option>, + allowed_methods: Option>, +} + +impl ResponderConfig { + pub fn from_path(config_path: &str) -> ResponderConfig { + let file = File::open(config_path) + .unwrap_or_else(|e| panic!("unable to read file {}\n{:?}", config_path, e)); + let mut file_reader = BufReader::new(file); + let mut file_buffer = vec![]; + file_reader + .read_to_end(&mut file_buffer) + .unwrap_or_else(|e| panic!("unable to read file {}\n{:?}", config_path, e)); + + let config_file: ResponderConfig = match toml::from_slice(&file_buffer) { + Ok(s) => s, + Err(e) => { + panic!("Config file malformatted {}", e.to_string()); + } + }; + config_file + } +} + +impl Responder { + pub fn new( + config: ResponderConfig, + headers: HeaderMap, + ) -> Result { + Ok(Responder { + allowed_origins: config.allowed_origins.unwrap_or_default(), + allowed_methods: config.allowed_methods.unwrap_or_default(), + headers, + }) + } + + pub fn response_builder(&self) -> Builder { + let mut builder = Response::builder(); + + for method in &self.allowed_methods { + builder = builder.header(ACCESS_CONTROL_ALLOW_METHODS, method); + } + + match self.headers.get(ORIGIN) { + Some(header_value) => { + if self.allowed_origins.clone().into_iter().any(|h| h == "*") { + builder = builder.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + return builder; + } + for allowed_host in &self.allowed_origins { + if header_value == allowed_host { + builder = builder.header(ACCESS_CONTROL_ALLOW_ORIGIN, allowed_host); + break; + } + } + return builder; + } + None => builder, + } + } + + fn _respond(&self, code: StatusCode, body: String) -> Result, Infallible> { + let builder = self.response_builder(); + match builder.status(code).body(Body::try_from(body).unwrap()) { + Ok(r) => Ok(r), + Err(_) => unreachable!(), + } + } + + pub fn respond(&self, code: u16, body: String) -> Result, Infallible> { + self._respond(StatusCode::from_u16(code).unwrap(), body) + } + + pub fn ok(&self) -> Result, Infallible> { + self._respond(StatusCode::OK, "Ok".into()) + } + + pub fn err_method_not_allowed(&self, body: String) -> Result, Infallible> { + self._respond(StatusCode::METHOD_NOT_ALLOWED, body) + } + + pub fn err_bad_request(&self, body: String) -> Result, Infallible> { + self._respond(StatusCode::BAD_REQUEST, body) + } + + pub fn err_not_found(&self, body: String) -> Result, Infallible> { + self._respond(StatusCode::NOT_FOUND, body) + } + + pub fn err_not_implemented(&self, body: String) -> Result, Infallible> { + self._respond(StatusCode::NOT_FOUND, body) + } + + pub fn err_internal(&self, body: String) -> Result, Infallible> { + self._respond(StatusCode::INTERNAL_SERVER_ERROR, body) + } +} diff --git a/src/routes.rs b/src/routes.rs index 614de41..663e35d 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -5,82 +5,62 @@ use std::{convert::Infallible, str::FromStr}; use crate::{ config::StacksDevnetConfig, resources::service::{get_service_from_path_part, get_service_url, get_user_facing_port}, + responder::Responder, Context, StacksDevnetApiK8sManager, }; pub async fn handle_new_devnet( request: Request, k8s_manager: StacksDevnetApiK8sManager, + responder: Responder, ctx: Context, ) -> Result, Infallible> { let body = hyper::body::to_bytes(request.into_body()).await; if body.is_err() { let msg = "failed to parse request body"; ctx.try_log(|logger| slog::error!(logger, "{}", msg)); - return Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::try_from(msg).unwrap()) - .unwrap()); + return responder.err_internal(msg.into()); } let body = body.unwrap(); let config: Result = serde_json::from_slice(&body); match config { Ok(config) => match config.to_validated_config(ctx) { Ok(config) => match k8s_manager.deploy_devnet(config).await { - Ok(_) => Ok(Response::builder() - .status(StatusCode::OK) - .body(Body::empty()) - .unwrap()), - Err(e) => Ok(Response::builder() - .status(StatusCode::from_u16(e.code).unwrap()) - .body(Body::try_from(e.message).unwrap()) - .unwrap()), + Ok(_) => responder.ok(), + Err(e) => responder.respond(e.code, e.message), }, - Err(e) => Ok(Response::builder() - .status(StatusCode::from_u16(e.code).unwrap()) - .body(Body::try_from(e.message).unwrap()) - .unwrap()), + Err(e) => responder.respond(e.code, e.message), }, - Err(e) => Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body( - Body::try_from(format!("invalid configuration to create network: {}", e)).unwrap(), - ) - .unwrap()), + Err(e) => { + responder.err_bad_request(format!("invalid configuration to create network: {}", e)) + } } } pub async fn handle_delete_devnet( k8s_manager: StacksDevnetApiK8sManager, network: &str, + responder: Responder, ) -> Result, Infallible> { match k8s_manager.delete_devnet(network).await { - Ok(_) => Ok(Response::builder() - .status(StatusCode::OK) - .body(Body::empty()) - .unwrap()), - Err(e) => Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body( - Body::try_from(format!( - "error deleting network {}: {}", - &network, - e.to_string() - )) - .unwrap(), - ) - .unwrap()), + Ok(_) => responder.ok(), + Err(e) => { + let msg = format!("error deleting network {}: {}", &network, e.to_string()); + responder.err_internal(msg) + } } } pub async fn handle_get_devnet( k8s_manager: StacksDevnetApiK8sManager, network: &str, + responder: Responder, ctx: Context, ) -> Result, Infallible> { match k8s_manager.get_devnet_info(&network).await { Ok(devnet_info) => match serde_json::to_vec(&devnet_info) { - Ok(body) => Ok(Response::builder() + Ok(body) => Ok(responder + .response_builder() .status(StatusCode::OK) .header("Content-Type", "application/json") .body(Body::from(body)) @@ -92,16 +72,10 @@ pub async fn handle_get_devnet( e.to_string() ); ctx.try_log(|logger: &hiro_system_kit::Logger| slog::error!(logger, "{}", msg)); - Ok(Response::builder() - .status(StatusCode::from_u16(500).unwrap()) - .body(Body::try_from(msg).unwrap()) - .unwrap()) + responder.err_internal(msg) } }, - Err(e) => Ok(Response::builder() - .status(StatusCode::from_u16(e.code).unwrap()) - .body(Body::try_from(e.message).unwrap()) - .unwrap()), + Err(e) => responder.respond(e.code, e.message), } } @@ -110,6 +84,7 @@ pub async fn handle_try_proxy_service( subroute: &str, network: &str, request: Request, + responder: Responder, ctx: &Context, ) -> Result, Infallible> { let service = get_service_from_path_part(subroute); @@ -119,12 +94,9 @@ pub async fn handle_try_proxy_service( let port = get_user_facing_port(service).unwrap(); let forward_url = format!("{}:{}", base_url, port); let proxy_request = mutate_request_for_proxy(request, &forward_url, &remaining_path); - proxy(proxy_request, &ctx).await + proxy(proxy_request, responder, &ctx).await } - None => Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::try_from("invalid request path").unwrap()) - .unwrap()), + None => responder.err_bad_request("invalid request path".into()), }; } @@ -146,7 +118,11 @@ pub fn mutate_request_for_proxy( request } -async fn proxy(request: Request, ctx: &Context) -> Result, Infallible> { +async fn proxy( + request: Request, + responder: Responder, + ctx: &Context, +) -> Result, Infallible> { let client = Client::new(); ctx.try_log(|logger| slog::info!(logger, "forwarding request to {}", request.uri())); @@ -155,10 +131,7 @@ async fn proxy(request: Request, ctx: &Context) -> Result, Err(e) => { let msg = format!("error proxying request: {}", e.to_string()); ctx.try_log(|logger| slog::error!(logger, "{}", msg)); - Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::try_from(msg).unwrap()) - .unwrap()) + responder.err_internal(msg) } } } diff --git a/templates/stacks-devnet-api.template.yaml b/templates/stacks-devnet-api.template.yaml index 4cb8458..860318d 100644 --- a/templates/stacks-devnet-api.template.yaml +++ b/templates/stacks-devnet-api.template.yaml @@ -58,6 +58,13 @@ spec: - containerPort: 8477 name: api protocol: TCP + volumeMounts: + - name: config-volume + mountPath: /etc/config + volumes: + - name: config-volume + configMap: + name: stacks-devnet-api-conf --- apiVersion: v1