diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53a20cf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +/target/ +/examples/ +/dockerfiles/ +/Dockerfile +/.dockerignore +/.git* +/scripts/ +/logs/ + diff --git a/Cargo.lock b/Cargo.lock index 55b121b..ef71431 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,10 +24,26 @@ dependencies = [ ] [[package]] -name = "ascii" -version = "1.1.0" +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] [[package]] name = "async-trait" @@ -118,12 +134,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "chunked_transfer" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca491388666e04d7248af3f60f0c40cfb0991c72205595d7c396e3510207d1a" - [[package]] name = "codespan-reporting" version = "0.11.1" @@ -352,6 +362,25 @@ dependencies = [ "wasi", ] +[[package]] +name = "h2" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d357c7ae988e7d2182f7d7871d0b963962420b0678b0997ce7de72001aeab782" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -417,6 +446,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "httparse", @@ -1032,26 +1062,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_qs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0431a35568651e363364210c91983c1da5eb29404d9f0928b67d4ebcfa7d330c" -dependencies = [ - "percent-encoding", - "serde", - "thiserror", -] - -[[package]] -name = "serde_spanned" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" -dependencies = [ - "serde", -] - [[package]] name = "serde_yaml" version = "0.9.21" @@ -1104,16 +1114,16 @@ name = "stacks-devnet-api" version = "0.1.0" dependencies = [ "futures", + "http-body", + "hyper", "k8s-openapi", "kube", "serde", "serde_json", - "serde_qs", "serde_yaml", - "tiny_http", "tokio", - "toml", - "url", + "tower", + "tower-test", ] [[package]] @@ -1167,18 +1177,6 @@ dependencies = [ "syn 2.0.15", ] -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - [[package]] name = "tinyvec" version = "1.6.0" @@ -1247,52 +1245,42 @@ dependencies = [ ] [[package]] -name = "tokio-util" -version = "0.7.7" +name = "tokio-stream" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" dependencies = [ - "bytes", "futures-core", - "futures-sink", "pin-project-lite", - "slab", "tokio", - "tracing", -] - -[[package]] -name = "toml" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", ] [[package]] -name = "toml_datetime" -version = "0.6.1" +name = "tokio-test" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3" dependencies = [ - "serde", + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", ] [[package]] -name = "toml_edit" -version = "0.19.8" +name = "tokio-util" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" +checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", + "tracing", ] [[package]] @@ -1345,6 +1333,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +[[package]] +name = "tower-test" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4546773ffeab9e4ea02b8872faa49bb616a80a7da66afc2f32688943f97efa7" +dependencies = [ + "futures-util", + "pin-project", + "tokio", + "tokio-test", + "tower-layer", + "tower-service", +] + [[package]] name = "tracing" version = "0.1.37" @@ -1682,15 +1684,6 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" -[[package]] -name = "winnow" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" -dependencies = [ - "memchr", -] - [[package]] name = "zeroize" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index ffdf0ff..dc16a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,9 @@ tokio = { version = "1.27.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.96" serde_yaml = "0.9.21" -toml = "0.7.3" -tiny_http = "0.12.0" -url = "2.3.1" -serde_qs = "0.12.0" +hyper = { version = "0.14", features = ["full"] } +tower = "0.4.13" +http-body = "0.4.5" + +[dev-dependencies] +tower-test = "0.4.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e98f24c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM arm64v8/rust:1.67 as builder + +WORKDIR ./ +COPY . ./ + +RUN cargo build --release --manifest-path ./Cargo.toml + +FROM gcr.io/distroless/cc +COPY --from=builder target/release/stacks-devnet-api / + +ENTRYPOINT ["./stacks-devnet-api"] \ No newline at end of file diff --git a/README.md b/README.md index 80378f1..58f7d58 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,3 @@ cargo run to start the server. Currently, the server is hosted on `localhost:8477` and exposes two routes: - `POST localhost:8477/api/v1/networks` - Creates a new devnet with configuration provided in request body. See [this example](./examples/new-network.example.json) object for the required parameters. - `DELETE localhost:8477/api/v1/network?network={namespace}` - Deletes all k8s assets deployed under the given namespace. - -### Notes -This project is still very eary in development and the code is fragile and will change a lot. Some known issues: - - if a k8s deployment fails, the app crashes. K8s deployments fail for a lot of reasons, so you'll need to restart the service a lot. - - the project relies on a docker image called `stacks-network`, which is not yet deployed to docker hub. This is in progress. \ No newline at end of file diff --git a/scripts/kind-deploy.sh b/scripts/kind-deploy.sh index ae7dab4..0168de6 100755 --- a/scripts/kind-deploy.sh +++ b/scripts/kind-deploy.sh @@ -1,6 +1,5 @@ kind create cluster --config=./templates/initial-config/kind.yaml && \ docker pull hirosystems/stacks-blockchain-api:latest --platform=linux/amd64 && \ kind load docker-image hirosystems/stacks-blockchain-api && \ -kind load docker-image stacks-network && \ kubectl --context kind-kind apply -f https://openebs.github.io/charts/openebs-operator.yaml && \ kubectl --context kind-kind apply -f ./templates/initial-config/storage-class.yaml \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 19c28e9..a159e78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ +use hyper::{body::Bytes, Body, Request, Response}; use k8s_openapi::{ api::core::v1::{ConfigMap, Namespace, PersistentVolumeClaim, Pod, Service}, NamespaceResourceScope, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{collections::BTreeMap, time::Duration}; +use tower::BoxError; use kube::{ api::{Api, DeleteParams, PostParams, ResourceExt}, @@ -14,6 +16,14 @@ use std::thread::sleep; mod template_parser; use template_parser::{get_yaml_from_filename, Template}; +const BITCOIND_CHAIN_COORDINATOR_SERVICE_NAME: &str = "bitcoind-chain-coordinator-service"; +const STACKS_NODE_SERVICE_NAME: &str = "stacks-node-service"; + +const BITCOIND_P2P_PORT: i32 = 18444; +const BITCOIND_RPC_PORT: i32 = 18443; +const STACKS_NODE_P2P_PORT: i32 = 20444; +const STACKS_NODE_RPC_PORT: i32 = 20443; +const CHAIN_COORDINATOR_INGESTION_PORT: i32 = 20445; #[derive(Serialize, Deserialize, Debug)] pub struct StacksDevnetConfig { namespace: String, @@ -51,141 +61,246 @@ pub struct StacksDevnetConfig { miner_stx_address: String, } -const BITCOIND_CHAIN_COORDINATOR_SERVICE_NAME: &str = "bitcoind-chain-coordinator-service"; -const STACKS_NODE_SERVICE_NAME: &str = "stacks-node-service"; +pub struct DevNetError { + pub message: String, + pub code: u16, +} -const BITCOIND_P2P_PORT: &str = "18444"; -const BITCOIND_RPC_PORT: &str = "18443"; -const STACKS_NODE_P2P_PORT: &str = "20444"; -const STACKS_NODE_RPC_PORT: &str = "20443"; -const CHAIN_COORDINATOR_INGESTION_PORT: &str = "20445"; +#[derive(Clone)] +pub struct StacksDevnetApiK8sManager { + client: Client, +} -pub async fn deploy_devnet(config: StacksDevnetConfig) -> Result<(), Box> { - let namespace = &config.namespace; +impl StacksDevnetApiK8sManager { + pub async fn default() -> StacksDevnetApiK8sManager { + let client = Client::try_default() + .await + .expect("could not create kube client"); + StacksDevnetApiK8sManager { client } + } + + pub async fn new(service: S, default_namespace: T) -> StacksDevnetApiK8sManager + where + S: tower::Service, Response = Response> + Send + 'static, + S::Future: Send + 'static, + S::Error: Into, + B: http_body::Body + Send + 'static, + B::Error: Into, + T: Into, + { + let client = Client::new(service, default_namespace); + StacksDevnetApiK8sManager { client } + } - deploy_namespace(&namespace).await?; - deploy_bitcoin_node_pod(&config).await?; + pub async fn deploy_devnet(&self, config: StacksDevnetConfig) -> Result<(), DevNetError> { + let namespace = &config.namespace; + + let namespace_exists = &self.check_namespace_exists(&namespace).await?; + if !namespace_exists { + if cfg!(debug_assertions) { + self.deploy_namespace(&namespace).await?; + } else { + return Err(DevNetError { + message: "Cannot create devnet before namespace exists.".into(), + code: 400, + }); + } + } + self.deploy_bitcoin_node_pod(&config).await?; - sleep(Duration::from_secs(5)); + sleep(Duration::from_secs(5)); - deploy_stacks_node_pod(&config).await?; + self.deploy_stacks_node_pod(&config).await?; - if !config.disable_stacks_api { - deploy_stacks_api_pod(&namespace).await?; + if !config.disable_stacks_api { + self.deploy_stacks_api_pod(&namespace).await?; + } + Ok(()) } - Ok(()) -} -pub async fn delete_devnet(namespace: &str) -> Result<(), Box> { - let _ = delete_namespace(namespace).await; - let _ = delete_resource::(namespace, "bitcoind-chain-coordinator").await; - let _ = delete_resource::(namespace, "stacks-node").await; - let _ = delete_resource::(namespace, "stacks-api").await; - let _ = delete_resource::(namespace, "bitcoind-conf").await; - let _ = delete_resource::(namespace, "stacks-node-conf").await; - let _ = delete_resource::(namespace, "stacks-api-conf").await; - let _ = delete_resource::(namespace, "stacks-api-postgres-conf").await; - let _ = delete_resource::(namespace, "deployment-plan-conf").await; - let _ = delete_resource::(namespace, "devnet-conf").await; - let _ = delete_resource::(namespace, "project-dir-conf").await; - let _ = delete_resource::(namespace, "namespace-conf").await; - let _ = delete_resource::(namespace, "project-manifest-conf").await; - let _ = delete_resource::(namespace, "bitcoind-chain-coordinator-service").await; - let _ = delete_resource::(namespace, "stacks-node-service").await; - let _ = delete_resource::(namespace, "stacks-api-service").await; - let _ = delete_resource::(namespace, "stacks-api-pvc").await; - Ok(()) -} + pub async fn delete_devnet(&self, namespace: &str) -> Result<(), Box> { + if cfg!(debug_assertions) { + let _ = self.delete_namespace(namespace).await; + } + let _ = self + .delete_resource::(namespace, "bitcoind-chain-coordinator") + .await; + let _ = self.delete_resource::(namespace, "stacks-node").await; + let _ = self.delete_resource::(namespace, "stacks-api").await; + let _ = self + .delete_resource::(namespace, "bitcoind-conf") + .await; + let _ = self + .delete_resource::(namespace, "stacks-node-conf") + .await; + let _ = self + .delete_resource::(namespace, "stacks-api-conf") + .await; + let _ = self + .delete_resource::(namespace, "stacks-api-postgres-conf") + .await; + let _ = self + .delete_resource::(namespace, "deployment-plan-conf") + .await; + let _ = self + .delete_resource::(namespace, "devnet-conf") + .await; + let _ = self + .delete_resource::(namespace, "project-dir-conf") + .await; + let _ = self + .delete_resource::(namespace, "namespace-conf") + .await; + let _ = self + .delete_resource::(namespace, "project-manifest-conf") + .await; + let _ = self + .delete_resource::(namespace, "bitcoind-chain-coordinator-service") + .await; + let _ = self + .delete_resource::(namespace, "stacks-node-service") + .await; + let _ = self + .delete_resource::(namespace, "stacks-api-service") + .await; + let _ = self + .delete_resource::(namespace, "stacks-api-pvc") + .await; + Ok(()) + } -async fn deploy_namespace(namespace_str: &str) -> Result<(), Box> { - let client = Client::try_default().await?; - let namespace_api: Api = kube::Api::all(client); + pub async fn check_namespace_exists(&self, namespace_str: &str) -> Result { + let namespace_api: Api = kube::Api::all(self.client.to_owned()); + match namespace_api.get(namespace_str).await { + Ok(_) => Ok(true), + Err(kube::Error::Api(api_error)) => { + if api_error.code == 404 { + Ok(false) + } else { + Err(DevNetError { + message: format!("unable to get namespace: {}", api_error.message), + code: api_error.code, + }) + } + } + Err(e) => Err(DevNetError { + message: format!("unable to get namespace: {}", e.to_string()), + code: 500, + }), + } + } - let template_str = get_yaml_from_filename(Template::Namespace); - let mut namespace: Namespace = serde_yaml::from_str(template_str)?; + async fn deploy_namespace(&self, namespace_str: &str) -> Result<(), DevNetError> { + let mut namespace: Namespace = get_resource_from_file(Template::Namespace)?; - namespace.metadata.name = Some(namespace_str.to_owned()); - namespace.metadata.labels = Some(BTreeMap::from([("name".into(), namespace_str.to_owned())])); + namespace.metadata.name = Some(namespace_str.to_owned()); + namespace.metadata.labels = + Some(BTreeMap::from([("name".into(), namespace_str.to_owned())])); - let post_params = PostParams::default(); - let created_namespace = namespace_api.create(&post_params, &namespace).await?; - let name = created_namespace.name_any(); - assert_eq!(namespace.name_any(), name); - println!("Created {}", name); - Ok(()) -} + let namespace_api: Api = kube::Api::all(self.client.to_owned()); -async fn deploy_pod(template: Template, namespace: &str) -> Result<(), Box> { - let client = Client::try_default().await?; - let pods_api: Api = Api::namespaced(client, &namespace); + let pp = PostParams::default(); + match namespace_api.create(&pp, &namespace).await { + Ok(namespace) => { + let name = namespace.name_any(); + println!("created namespace {}", name); + Ok(()) + } + Err(kube::Error::Api(api_error)) => Err(DevNetError { + message: format!("unable to create namespace: {}", api_error.message), + code: api_error.code, + }), + Err(e) => Err(DevNetError { + message: format!("unable to create namespace: {}", e.to_string()), + code: 500, + }), + } + } - let template_str = get_yaml_from_filename(template); - let mut pod: Pod = serde_yaml::from_str(template_str)?; - pod.metadata.namespace = Some(namespace.to_owned()); - - let pp = PostParams::default(); - let response = pods_api.create(&pp, &pod).await?; - let name = response.name_any(); - println!("created pod {}", name); - Ok(()) -} + async fn deploy_resource>( + &self, + namespace: &str, + resource: K, + resource_name: &str, + ) -> Result<(), DevNetError> + where + ::DynamicType: Default, + K: Clone, + K: DeserializeOwned, + K: std::fmt::Debug, + K: Serialize, + { + let resource_api: Api = Api::namespaced(self.client.to_owned(), &namespace); + let pp = PostParams::default(); -async fn deploy_service( - template: Template, - namespace: &str, -) -> Result<(), Box> { - let client = Client::try_default().await?; - let service_api: Api = Api::namespaced(client, &namespace); + match resource_api.create(&pp, &resource).await { + Ok(resource) => { + let name = resource.name_any(); + println!("created {} {}", resource_name, name); + Ok(()) + } + Err(kube::Error::Api(api_error)) => Err(DevNetError { + message: format!("unable to create {}: {}", resource_name, api_error.message), + code: api_error.code, + }), + Err(e) => Err(DevNetError { + message: format!("unable to create {}: {}", resource_name, e.to_string()), + code: 500, + }), + } + } - let template_str = get_yaml_from_filename(template); - let mut service: Service = serde_yaml::from_str(template_str)?; - service.metadata.namespace = Some(namespace.to_owned()); - - let pp = PostParams::default(); - let response = service_api.create(&pp, &service).await?; - let name = response.name_any(); - println!("created service {}", name); - Ok(()) -} + async fn deploy_pod(&self, template: Template, namespace: &str) -> Result<(), DevNetError> { + let mut pod: Pod = get_resource_from_file(template)?; -async fn deploy_configmap( - template: Template, - namespace: &str, - configmap_data: Option>, -) -> Result<(), Box> { - let client = Client::try_default().await?; - let config_map_api: Api = kube::Api::::namespaced(client, &namespace); + pod.metadata.namespace = Some(namespace.to_owned()); + self.deploy_resource(namespace, pod, "pod").await + } - let template_str = get_yaml_from_filename(template); - let mut configmap: ConfigMap = serde_yaml::from_str(template_str)?; + async fn deploy_service(&self, template: Template, namespace: &str) -> Result<(), DevNetError> { + let mut service: Service = get_resource_from_file(template)?; - configmap.metadata.namespace = Some(namespace.to_owned()); - if let Some(configmap_data) = configmap_data { - let mut map = BTreeMap::new(); - for (key, value) in configmap_data { - map.insert(key.into(), value.into()); + service.metadata.namespace = Some(namespace.to_owned()); + self.deploy_resource(namespace, service, "service").await + } + + async fn deploy_configmap( + &self, + template: Template, + namespace: &str, + configmap_data: Option>, + ) -> Result<(), DevNetError> { + let mut configmap: ConfigMap = get_resource_from_file(template)?; + + configmap.metadata.namespace = Some(namespace.to_owned()); + if let Some(configmap_data) = configmap_data { + let mut map = BTreeMap::new(); + for (key, value) in configmap_data { + map.insert(key.into(), value.into()); + } + configmap.data = Some(map); } - configmap.data = Some(map); + + self.deploy_resource(namespace, configmap, "configmap") + .await } - let post_params = PostParams::default(); - let created_config = config_map_api.create(&post_params, &configmap).await?; - let name = created_config.name_any(); - assert_eq!(configmap.name_any(), name); - println!("Created {}", name); - Ok(()) -} + async fn deploy_pvc(&self, template: Template, namespace: &str) -> Result<(), DevNetError> { + let mut pvc: PersistentVolumeClaim = get_resource_from_file(template)?; -async fn deploy_bitcoin_node_pod( - config: &StacksDevnetConfig, -) -> Result<(), Box> { - let bitcoind_p2p_port = BITCOIND_P2P_PORT.parse::()?; - let bitcoind_rpc_port = BITCOIND_RPC_PORT.parse::()?; + pvc.metadata.namespace = Some(namespace.to_owned()); - let namespace = &config.namespace; + self.deploy_resource(namespace, pvc, "pvc").await + } - let bitcoind_conf = format!( - r#" + async fn deploy_bitcoin_node_pod( + &self, + config: &StacksDevnetConfig, + ) -> Result<(), DevNetError> { + let namespace = &config.namespace; + + let bitcoind_conf = format!( + r#" server=1 regtest=1 rpcallowip=0.0.0.0/0 @@ -208,87 +323,85 @@ async fn deploy_bitcoin_node_pod( rpcbind=0.0.0.0:{} rpcport={} "#, - config.bitcoin_node_username, - config.bitcoin_node_password, - bitcoind_p2p_port, - bitcoind_rpc_port, - bitcoind_rpc_port - ); - - deploy_configmap( - Template::BitcoindConfigmap, - &namespace, - Some(vec![("bitcoin.conf", &bitcoind_conf)]), - ) - .await?; - - deploy_configmap( - Template::ChainCoordinatorNamespaceConfigmap, - &namespace, - Some(vec![("NAMESPACE", &namespace)]), - ) - .await?; - - deploy_configmap( - Template::ChainCoordinatorProjectManifestConfigmap, - &namespace, - Some(vec![("Clarinet.toml", &config.project_manifest)]), - ) - .await?; - - let mut devnet_config = config.devnet_config.clone(); - //devnet_config.push_str("\n[devnet]"); - devnet_config.push_str(&format!( - "\nbitcoin_node_username = \"{}\"", - &config.bitcoin_node_username - )); - devnet_config.push_str(&format!( - "\nbitcoin_node_password = \"{}\"", - &config.bitcoin_node_password - )); - println!("{}", devnet_config); - deploy_configmap( - Template::ChainCoordinatorDevnetConfigmap, - &namespace, - Some(vec![("Devnet.toml", &devnet_config)]), - ) - .await?; - - deploy_configmap( - Template::ChainCoordinatorDeploymentPlanConfigmap, - &namespace, - Some(vec![("default.devnet-plan.yaml", &config.deployment_plan)]), - ) - .await?; - - let mut contracts: Vec<(&str, &str)> = vec![]; - for (contract_name, contract_source) in &config.contracts { - contracts.push((contract_name, contract_source)); - } - deploy_configmap( - Template::ChainCoordinatorProjectDirConfigmap, - &namespace, - Some(contracts), - ) - .await?; + config.bitcoin_node_username, + config.bitcoin_node_password, + BITCOIND_P2P_PORT, + BITCOIND_RPC_PORT, + BITCOIND_RPC_PORT + ); - deploy_pod(Template::BitcoindChainCoordinatorPod, &namespace).await?; + self.deploy_configmap( + Template::BitcoindConfigmap, + &namespace, + Some(vec![("bitcoin.conf", &bitcoind_conf)]), + ) + .await?; + + self.deploy_configmap( + Template::ChainCoordinatorNamespaceConfigmap, + &namespace, + Some(vec![("NAMESPACE", &namespace)]), + ) + .await?; + + self.deploy_configmap( + Template::ChainCoordinatorProjectManifestConfigmap, + &namespace, + Some(vec![("Clarinet.toml", &config.project_manifest)]), + ) + .await?; + + let mut devnet_config = config.devnet_config.clone(); + //devnet_config.push_str("\n[devnet]"); + devnet_config.push_str(&format!( + "\nbitcoin_node_username = \"{}\"", + &config.bitcoin_node_username + )); + devnet_config.push_str(&format!( + "\nbitcoin_node_password = \"{}\"", + &config.bitcoin_node_password + )); - deploy_service(Template::BitcoindChainCoordinatorService, namespace).await?; + self.deploy_configmap( + Template::ChainCoordinatorDevnetConfigmap, + &namespace, + Some(vec![("Devnet.toml", &devnet_config)]), + ) + .await?; + + self.deploy_configmap( + Template::ChainCoordinatorDeploymentPlanConfigmap, + &namespace, + Some(vec![("default.devnet-plan.yaml", &config.deployment_plan)]), + ) + .await?; + + let mut contracts: Vec<(&str, &str)> = vec![]; + for (contract_name, contract_source) in &config.contracts { + contracts.push((contract_name, contract_source)); + } + self.deploy_configmap( + Template::ChainCoordinatorProjectDirConfigmap, + &namespace, + Some(contracts), + ) + .await?; - Ok(()) -} + self.deploy_pod(Template::BitcoindChainCoordinatorPod, &namespace) + .await?; -async fn deploy_stacks_node_pod( - config: &StacksDevnetConfig, -) -> Result<(), Box> { - let p2p_port = STACKS_NODE_P2P_PORT.parse::()?; - let rpc_port = STACKS_NODE_RPC_PORT.parse::()?; - let namespace = &config.namespace; + self.deploy_service(Template::BitcoindChainCoordinatorService, namespace) + .await?; - let stacks_conf = { - let mut stacks_conf = format!( - r#" + Ok(()) + } + + async fn deploy_stacks_node_pod(&self, config: &StacksDevnetConfig) -> Result<(), DevNetError> { + let namespace = &config.namespace; + + let stacks_conf = { + let mut stacks_conf = format!( + r#" [node] working_dir = "/devnet" rpc_bind = "0.0.0.0:{}" @@ -316,45 +429,45 @@ async fn deploy_stacks_node_pod( block_reward_recipient = "{}" # microblock_attempt_time_ms = 15000 "#, - rpc_port, - p2p_port, - config.stacks_miner_secret_key_hex, - config.stacks_miner_secret_key_hex, - config.stacks_node_wait_time_for_microblocks, - config.stacks_node_first_attempt_time_ms, - config.stacks_node_subsequent_attempt_time_ms, - config.miner_coinbase_recipient - ); - - for (address, balance) in config.accounts.iter() { - stacks_conf.push_str(&format!( - r#" + STACKS_NODE_RPC_PORT, + STACKS_NODE_P2P_PORT, + config.stacks_miner_secret_key_hex, + config.stacks_miner_secret_key_hex, + config.stacks_node_wait_time_for_microblocks, + config.stacks_node_first_attempt_time_ms, + config.stacks_node_subsequent_attempt_time_ms, + config.miner_coinbase_recipient + ); + + for (address, balance) in config.accounts.iter() { + stacks_conf.push_str(&format!( + r#" [[ustx_balance]] address = "{}" amount = {} "#, - address, balance - )); - } + address, balance + )); + } - let balance: u64 = 100_000_000_000_000; - stacks_conf.push_str(&format!( - r#" + let balance: u64 = 100_000_000_000_000; + stacks_conf.push_str(&format!( + r#" [[ustx_balance]] address = "{}" amount = {} "#, - config.miner_coinbase_recipient, balance - )); + config.miner_coinbase_recipient, balance + )); - let namespaced_host = format!("{}.svc.cluster.local", &namespace); - let bitcoind_chain_coordinator_host = format!( - "{}.{}", - &BITCOIND_CHAIN_COORDINATOR_SERVICE_NAME, namespaced_host - ); + let namespaced_host = format!("{}.svc.cluster.local", &namespace); + let bitcoind_chain_coordinator_host = format!( + "{}.{}", + &BITCOIND_CHAIN_COORDINATOR_SERVICE_NAME, namespaced_host + ); - stacks_conf.push_str(&format!( - r#" + stacks_conf.push_str(&format!( + r#" # Add orchestrator (docker-host) as an event observer [[events_observer]] endpoint = "{}:{}" @@ -362,23 +475,23 @@ async fn deploy_stacks_node_pod( include_data_events = true events_keys = ["*"] "#, - bitcoind_chain_coordinator_host, CHAIN_COORDINATOR_INGESTION_PORT - )); + bitcoind_chain_coordinator_host, CHAIN_COORDINATOR_INGESTION_PORT + )); - // stacks_conf.push_str(&format!( - // r#" - // # Add stacks-api as an event observer - // [[events_observer]] - // endpoint = "host.docker.internal:{}" - // retry_count = 255 - // include_data_events = false - // events_keys = ["*"] - // "#, - // 30007, - // )); - - stacks_conf.push_str(&format!( - r#" + // stacks_conf.push_str(&format!( + // r#" + // # Add stacks-api as an event observer + // [[events_observer]] + // endpoint = "host.docker.internal:{}" + // retry_count = 255 + // include_data_events = false + // events_keys = ["*"] + // "#, + // 30007, + // )); + + stacks_conf.push_str(&format!( + r#" [burnchain] chain = "bitcoin" mode = "krypton" @@ -392,15 +505,15 @@ async fn deploy_stacks_node_pod( rpc_port = {} peer_port = {} "#, - bitcoind_chain_coordinator_host, - config.bitcoin_node_username, - config.bitcoin_node_password, - CHAIN_COORDINATOR_INGESTION_PORT, - BITCOIND_P2P_PORT - )); + bitcoind_chain_coordinator_host, + config.bitcoin_node_username, + config.bitcoin_node_password, + CHAIN_COORDINATOR_INGESTION_PORT, + BITCOIND_P2P_PORT + )); - stacks_conf.push_str(&format!( - r#" + stacks_conf.push_str(&format!( + r#" pox_2_activation = {} [[burnchain.epochs]] @@ -419,117 +532,144 @@ async fn deploy_stacks_node_pod( epoch_name = "2.1" start_height = {} "#, - config.pox_2_activation, config.epoch_2_0, config.epoch_2_05, config.epoch_2_1 - )); - stacks_conf - }; - - deploy_configmap( - Template::StacksNodeConfigmap, - &namespace, - Some(vec![("Stacks.toml", &stacks_conf)]), - ) - .await?; - - deploy_pod(Template::StacksNodePod, &namespace).await?; + config.pox_2_activation, config.epoch_2_0, config.epoch_2_05, config.epoch_2_1 + )); + stacks_conf + }; - deploy_service(Template::StacksNodeService, namespace).await?; + self.deploy_configmap( + Template::StacksNodeConfigmap, + &namespace, + Some(vec![("Stacks.toml", &stacks_conf)]), + ) + .await?; - Ok(()) -} + self.deploy_pod(Template::StacksNodePod, &namespace).await?; -async fn deploy_stacks_api_pod(namespace: &str) -> Result<(), Box> { - // configmap env vars for pg conatainer - let stacks_api_pg_env = Vec::from([ - ("POSTGRES_PASSWORD", "postgres"), - ("POSTGRES_DB", "stacks_api"), - ]); - deploy_configmap( - Template::StacksApiPostgresConfigmap, - &namespace, - Some(stacks_api_pg_env), - ) - .await?; - - // configmap env vars for api conatainer - let namespaced_host = format!("{}.svc.cluster.local", &namespace); - let stacks_node_host = format!("{}.{}", &STACKS_NODE_SERVICE_NAME, namespaced_host); - let stacks_api_env = Vec::from([ - ("STACKS_CORE_RPC_HOST", &stacks_node_host[..]), - ("STACKS_BLOCKCHAIN_API_DB", "pg"), - ("STACKS_CORE_RPC_PORT", STACKS_NODE_RPC_PORT), - ("STACKS_BLOCKCHAIN_API_PORT", "3999"), - ("STACKS_BLOCKCHAIN_API_HOST", "0.0.0.0"), - ("STACKS_CORE_EVENT_PORT", "3700"), - ("STACKS_CORE_EVENT_HOST", "0.0.0.0"), - ("STACKS_API_ENABLE_FT_METADATA", "1"), - ("PG_HOST", "0.0.0.0"), - ("PG_PORT", "5432"), - ("PG_USER", "postgres"), - ("PG_PASSWORD", "postgres"), - ("PG_DATABASE", "stacks_api"), - ("STACKS_CHAIN_ID", "2147483648"), - ("V2_POX_MIN_AMOUNT_USTX", "90000000260"), - ("NODE_ENV", "production"), - ("STACKS_API_LOG_LEVEL", "debug"), - ]); - deploy_configmap( - Template::StacksApiConfigmap, - &namespace, - Some(stacks_api_env), - ) - .await?; - - // deploy persistent volume claim - { - let client = Client::try_default().await?; - let pvc_api: Api = Api::namespaced(client, &namespace); + self.deploy_service(Template::StacksNodeService, namespace) + .await?; - let template_str = get_yaml_from_filename(Template::StacksApiPvc); - let mut pvc: PersistentVolumeClaim = serde_yaml::from_str(template_str)?; - pvc.metadata.namespace = Some(namespace.to_owned()); - - let pp = PostParams::default(); - let response = pvc_api.create(&pp, &pvc).await?; - let name = response.name_any(); - println!("created pod {}", name); + Ok(()) } - deploy_pod(Template::StacksApiPod, &namespace).await?; + async fn deploy_stacks_api_pod(&self, namespace: &str) -> Result<(), DevNetError> { + // configmap env vars for pg conatainer + let stacks_api_pg_env = Vec::from([ + ("POSTGRES_PASSWORD", "postgres"), + ("POSTGRES_DB", "stacks_api"), + ]); + self.deploy_configmap( + Template::StacksApiPostgresConfigmap, + &namespace, + Some(stacks_api_pg_env), + ) + .await?; + + // configmap env vars for api conatainer + let namespaced_host = format!("{}.svc.cluster.local", &namespace); + let stacks_node_host = format!("{}.{}", &STACKS_NODE_SERVICE_NAME, namespaced_host); + let rpc_port = STACKS_NODE_RPC_PORT.to_string(); + let stacks_api_env = Vec::from([ + ("STACKS_CORE_RPC_HOST", &stacks_node_host[..]), + ("STACKS_BLOCKCHAIN_API_DB", "pg"), + ("STACKS_CORE_RPC_PORT", &rpc_port), + ("STACKS_BLOCKCHAIN_API_PORT", "3999"), + ("STACKS_BLOCKCHAIN_API_HOST", "0.0.0.0"), + ("STACKS_CORE_EVENT_PORT", "3700"), + ("STACKS_CORE_EVENT_HOST", "0.0.0.0"), + ("STACKS_API_ENABLE_FT_METADATA", "1"), + ("PG_HOST", "0.0.0.0"), + ("PG_PORT", "5432"), + ("PG_USER", "postgres"), + ("PG_PASSWORD", "postgres"), + ("PG_DATABASE", "stacks_api"), + ("STACKS_CHAIN_ID", "2147483648"), + ("V2_POX_MIN_AMOUNT_USTX", "90000000260"), + ("NODE_ENV", "production"), + ("STACKS_API_LOG_LEVEL", "debug"), + ]); + self.deploy_configmap( + Template::StacksApiConfigmap, + &namespace, + Some(stacks_api_env), + ) + .await?; + + self.deploy_pvc(Template::StacksApiPvc, &namespace).await?; + + self.deploy_pod(Template::StacksApiPod, &namespace).await?; + + self.deploy_service(Template::StacksApiService, &namespace) + .await?; + + Ok(()) + } - deploy_service(Template::StacksApiService, &namespace).await?; + async fn delete_resource>( + &self, + namespace: &str, + resource_name: &str, + ) -> Result<(), DevNetError> + where + ::DynamicType: Default, + K: Clone, + K: DeserializeOwned, + K: std::fmt::Debug, + { + let api: Api = Api::namespaced(self.client.to_owned(), &namespace); + let dp = DeleteParams::default(); + match api.delete(resource_name, &dp).await { + Ok(resource) => { + resource.map_left(|del| { + assert_eq!(del.name_any(), resource_name); + println!("Deleting {resource_name} started"); + }); + Ok(()) + } + Err(kube::Error::Api(api_error)) => Err(DevNetError { + message: format!("unable to delete {}: {}", resource_name, api_error.message), + code: api_error.code, + }), + Err(e) => Err(DevNetError { + message: format!("unable to delete {}: {}", resource_name, e.to_string()), + code: 500, + }), + } + } - Ok(()) + async fn delete_namespace(&self, namespace_str: &str) -> Result<(), DevNetError> { + let api: Api = kube::Api::all(self.client.to_owned()); + + let dp = DeleteParams::default(); + match api.delete(namespace_str, &dp).await { + Ok(namespace) => { + namespace.map_left(|del| { + assert_eq!(del.name_any(), namespace_str); + println!("Deleting namespace started"); + }); + Ok(()) + } + Err(kube::Error::Api(api_error)) => Err(DevNetError { + message: format!("unable to delete namespace: {}", api_error.message), + code: api_error.code, + }), + Err(e) => Err(DevNetError { + message: format!("unable to delete namespace: {}", e.to_string()), + code: 500, + }), + } + } } -async fn delete_resource>( - namespace: &str, - resource_name: &str, -) -> Result<(), Box> +fn get_resource_from_file(template: Template) -> Result where - ::DynamicType: Default, - K: Clone, K: DeserializeOwned, - K: std::fmt::Debug, { - let client = Client::try_default().await?; - let api: Api = Api::namespaced(client, &namespace); - let dp = DeleteParams::default(); - api.delete(resource_name, &dp).await?.map_left(|del| { - assert_eq!(del.name_any(), resource_name); - println!("Deleting resource started: {:?}", del); - }); - Ok(()) -} - -async fn delete_namespace(namespace_str: &str) -> Result<(), Box> { - let client = Client::try_default().await?; - let api: Api = kube::Api::all(client); + let template_str = get_yaml_from_filename(template); - let dp = DeleteParams::default(); - api.delete(namespace_str, &dp).await?.map_left(|del| { - assert_eq!(del.name_any(), namespace_str); - println!("Deleting resource started: {:?}", del); - }); - Ok(()) + let resource: K = serde_yaml::from_str(template_str).map_err(|e| DevNetError { + message: format!("unable to parse template file: {}", e.to_string()), + code: 500, + })?; + Ok(resource) } diff --git a/src/main.rs b/src/main.rs index c325437..51b6728 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,62 +1,566 @@ +use hyper::server::conn::AddrStream; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Client, Method, Request, Response, Server, StatusCode, Uri}; +use stacks_devnet_api::{StacksDevnetApiK8sManager, StacksDevnetConfig}; +use std::net::IpAddr; use std::str::FromStr; - -use stacks_devnet_api::{delete_devnet, deploy_devnet, StacksDevnetConfig}; -use serde::Deserialize; -use tiny_http::{Method, Response, Server}; -use url::Url; - -#[derive(Deserialize, Debug)] -struct DeleteRequest { - network: String, -} +use std::{convert::Infallible, net::SocketAddr}; #[tokio::main] -async fn main() -> Result<(), Box> { - const HOST: &str = "127.0.0.1"; +async fn main() { + const HOST: &str = "0.0.0.0"; const PORT: &str = "8477"; let endpoint: String = HOST.to_owned() + ":" + PORT; + let addr: SocketAddr = endpoint.parse().expect("Could not parse ip:port."); + let k8s_manager = StacksDevnetApiK8sManager::default().await; + + let make_svc = make_service_fn(|conn: &AddrStream| { + let k8s_manager = k8s_manager.clone(); + let remote_addr = conn.remote_addr().ip(); + async move { + Ok::<_, Infallible>(service_fn(move |req| { + handle_request(remote_addr, req, k8s_manager.clone()) + })) + } + }); + + let server = Server::bind(&addr).serve(make_svc); + + println!("Running server on {:?}", addr); + + if let Err(e) = server.await { + eprintln!("server error: {}", e); + } +} + +fn mutate_request_for_proxy( + mut request: Request, + network: &str, + path_to_forward: &str, + proxy_data: ProxyData, +) -> Request { + let forward_url = format!( + "http://{}.{}.svc.cluster.local:{}", + proxy_data.destination_service, network, proxy_data.destination_port + ); + + let query = match request.uri().query() { + Some(query) => format!("?{}", query), + None => String::new(), + }; + + *request.uri_mut() = { + let forward_uri = format!("{}/{}{}", forward_url, path_to_forward, query); + Uri::from_str(forward_uri.as_str()) + } + .unwrap(); + request +} + +async fn proxy(request: Request) -> Result, Infallible> { + let client = Client::new(); + + println!("forwarding request to {}", request.uri()); + match client.request(request).await { + Ok(response) => Ok(response), + Err(_error) => Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::empty()) + .unwrap()), + } +} + +struct ProxyData { + destination_service: String, + destination_port: String, +} +fn get_proxy_data(proxy_path: &str) -> Option { + const BITCOIN_NODE_PATH: &str = "bitcoin-node"; + const STACKS_NODE_PATH: &str = "stacks-node"; + const STACKS_API_PATH: &str = "stacks-api"; + const BITCOIN_NODE_SERVICE: &str = "bitcoind-chain-coordinator-service"; + const STACKS_NODE_SERVICE: &str = "stacks-node-service"; + const STACKS_API_SERVICE: &str = "stacks-api-service"; + const BITCOIN_NODE_PORT: &str = "18443"; + const STACKS_NODE_PORT: &str = "20443"; + const STACKS_API_PORT: &str = "3999"; + + match proxy_path { + BITCOIN_NODE_PATH => Some(ProxyData { + destination_service: BITCOIN_NODE_SERVICE.into(), + destination_port: BITCOIN_NODE_PORT.into(), + }), + STACKS_NODE_PATH => Some(ProxyData { + destination_service: STACKS_NODE_SERVICE.into(), + destination_port: STACKS_NODE_PORT.into(), + }), + STACKS_API_PATH => Some(ProxyData { + destination_service: STACKS_API_SERVICE.into(), + destination_port: STACKS_API_PORT.into(), + }), + _ => None, + } +} - let server = Server::http(&endpoint).unwrap(); - loop { - // blocks until the next request is received - let mut request = match server.recv() { - Ok(rq) => rq, +const API_PATH: &str = "/api/v1/"; +#[derive(Default, PartialEq, Debug)] +struct PathParts { + route: String, + network: Option, + subroute: Option, + remainder: Option, +} +fn get_standardized_path_parts(path: &str) -> PathParts { + let path = path.replace(API_PATH, ""); + let path = path.trim_matches('/'); + let parts: Vec<&str> = path.split("/").collect(); + + match parts.len() { + 0 => PathParts { + route: String::new(), + ..Default::default() + }, + 1 => PathParts { + route: parts[0].into(), + ..Default::default() + }, + 2 => PathParts { + route: parts[0].into(), + network: Some(parts[1].into()), + ..Default::default() + }, + 3 => PathParts { + route: parts[0].into(), + network: Some(parts[1].into()), + subroute: Some(parts[2].into()), + ..Default::default() + }, + _ => { + let remainder = parts[3..].join("/"); + PathParts { + route: parts[0].into(), + network: Some(parts[1].into()), + subroute: Some(parts[2].into()), + remainder: Some(remainder), + } + } + } +} + +async fn handle_request( + _client_ip: IpAddr, + request: Request, + k8s_manager: StacksDevnetApiK8sManager, +) -> Result, Infallible> { + let uri = request.uri(); + let path = uri.path(); + let method = request.method(); + println!("received request, method: {}. path: {}", method, path); + + if path == "/api/v1/networks" { + return match method { + &Method::POST => { + let body = hyper::body::to_bytes(request.into_body()).await; + if body.is_err() { + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::try_from("failed to parse request body").unwrap()) + .unwrap()); + } + let body = body.unwrap(); + let config: Result = serde_json::from_slice(&body); + if config.is_err() { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::try_from("invalid configuration to create network").unwrap()) + .unwrap()); + } + let config = config.unwrap(); + 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(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::try_from("network creation must be a POST request").unwrap()) + .unwrap()), + }; + } 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()); + } + // 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()); + } + let network = path_parts.network.unwrap(); + + // verify that we have a valid namespace and the network actually exists + let exists = match k8s_manager.check_namespace_exists(&network).await { + Ok(exists) => exists, Err(e) => { - println!("error: {}", e); - break; + return Ok(Response::builder() + .status(StatusCode::from_u16(e.code).unwrap()) + .body(Body::try_from(e.message).unwrap()) + .unwrap()); } }; + if !exists { + return Ok(Response::builder() + .status(StatusCode::from_u16(404).unwrap()) + .body(Body::try_from("network does not exist").unwrap()) + .unwrap()); + } - let url = request.url(); - let full_url = format!("http://{}{}", &endpoint, url); - let url = Url::from_str(&full_url)?; - match request.method() { - Method::Post => match url.path() { - "/api/v1/networks" => { - let mut content = String::new(); - request.as_reader().read_to_string(&mut content).unwrap(); - let config: StacksDevnetConfig = serde_json::from_str(&content)?; - deploy_devnet(config).await?; - request.respond(Response::empty(200))? - } - _ => request.respond(Response::empty(404))?, - }, - Method::Delete => match url.path() { - "/api/v1/network" => { - if let Some(query) = url.query() { - let delete_request: DeleteRequest = serde_qs::from_str(query)?; - delete_devnet(&delete_request.network).await?; - request.respond(Response::empty(200))? - } else { - request.respond(Response::empty(400))?; - } + // 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 => 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::empty()) + .unwrap()), + }, + &Method::GET => Ok(Response::builder() + .status(StatusCode::NOT_IMPLEMENTED) + .body(Body::empty()) + .unwrap()), + _ => Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::empty()) + .unwrap()), + }; + } + let subroute = path_parts.subroute.unwrap(); + if subroute == "commands" { + return Ok(Response::builder() + .status(StatusCode::NOT_IMPLEMENTED) + .body(Body::empty()) + .unwrap()); + } else { + let remaining_path = path_parts.remainder.unwrap_or(String::new()); + + let proxy_data = get_proxy_data(&subroute); + return match proxy_data { + Some(proxy_data) => { + let proxy_request = + mutate_request_for_proxy(request, &network, &remaining_path, proxy_data); + proxy(proxy_request).await } - _ => request.respond(Response::empty(404))?, - }, - // TODO: respond with unimplemented - _ => request.respond(Response::empty(501))?, + None => Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::try_from("invalid request path").unwrap()) + .unwrap()), + }; + } + } + + Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::try_from("invalid request path").unwrap()) + .unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + use hyper::body; + use k8s_openapi::api::core::v1::Namespace; + use tower_test::mock::{self, Handle}; + + async fn mock_k8s_handler(handle: &mut Handle, Response>) { + let (request, send) = handle.next_request().await.expect("Service not called"); + + let (body, status) = match ( + request.method().as_str(), + request.uri().to_string().as_str(), + ) { + ("GET", "/api/v1/namespaces/test") => { + let pod: Namespace = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { + "name": "test", + "labels": { + "name": "test" + } + }, + })) + .unwrap(); + (serde_json::to_vec(&pod).unwrap(), 200) + } + ("GET", "/api/v1/namespaces/undeployed") => (vec![], 404), + _ => panic!("Unexpected API request {:?}", request), + }; + + send.send_response( + Response::builder() + .status(status) + .body(Body::from(body)) + .unwrap(), + ); + } + + #[tokio::test] + async fn it_responds_400_for_invalid_paths() { + let (mock_service, mut handle) = mock::pair::, Response>(); + let _spawned = tokio::spawn(async move { + mock_k8s_handler(&mut handle).await; + }); + + let k8s_manager = StacksDevnetApiK8sManager::new(mock_service, "default").await; + let client_ip: IpAddr = IpAddr::V4([0, 0, 0, 0].into()); + let invalid_paths = vec![ + "/path", + "/api", + "/api/v1", + "/api/v1/network2", + "/api/v1/network/test/invalid_path", + ]; + 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()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.body_mut(); + let bytes = body::to_bytes(body).await.unwrap().to_vec(); + let body_str = String::from_utf8(bytes).unwrap(); + assert_eq!(body_str, "invalid request path"); + } + } + + #[tokio::test] + async fn it_responds_404_undeployed_namespaces() { + let (mock_service, mut handle) = mock::pair::, Response>(); + let _spawned = tokio::spawn(async move { + mock_k8s_handler(&mut handle).await; + }); + + let k8s_manager = StacksDevnetApiK8sManager::new(mock_service, "default").await; + let client_ip: IpAddr = IpAddr::V4([0, 0, 0, 0].into()); + let path = "/api/v1/network/undeployed"; + + 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()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = response.body_mut(); + let bytes = body::to_bytes(body).await.unwrap().to_vec(); + let body_str = String::from_utf8(bytes).unwrap(); + assert_eq!(body_str, "network does not exist"); + } + + #[tokio::test] + async fn it_responds_400_missing_network() { + let (mock_service, mut handle) = mock::pair::, Response>(); + let _spawned = tokio::spawn(async move { + mock_k8s_handler(&mut handle).await; + }); + + let k8s_manager = StacksDevnetApiK8sManager::new(mock_service, "default").await; + let client_ip: IpAddr = IpAddr::V4([0, 0, 0, 0].into()); + let path = "/api/v1/network/"; + + 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()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.body_mut(); + let bytes = body::to_bytes(body).await.unwrap().to_vec(); + let body_str = String::from_utf8(bytes).unwrap(); + assert_eq!(body_str, "no network id provided"); + } + + #[tokio::test] + async fn network_creation_responds_405_for_non_post_requests() { + let (mock_service, mut handle) = mock::pair::, Response>(); + let _spawned = tokio::spawn(async move { + mock_k8s_handler(&mut handle).await; + }); + + let k8s_manager = StacksDevnetApiK8sManager::new(mock_service, "default").await; + let client_ip: IpAddr = IpAddr::V4([0, 0, 0, 0].into()); + let path = "/api/v1/networks"; + + let methods = ["GET", "DELETE"]; + 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()) + .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(); + let body_str = String::from_utf8(bytes).unwrap(); + assert_eq!(body_str, "network creation must be a POST request"); } } + #[tokio::test] + async fn network_creation_responds_400_for_invalid_config_data() { + let (mock_service, mut handle) = mock::pair::, Response>(); + let _spawned = tokio::spawn(async move { + mock_k8s_handler(&mut handle).await; + }); + + let k8s_manager = StacksDevnetApiK8sManager::new(mock_service, "default").await; + let client_ip: IpAddr = IpAddr::V4([0, 0, 0, 0].into()); + let path = "/api/v1/networks"; + + 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()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.body_mut(); + let bytes = body::to_bytes(body).await.unwrap().to_vec(); + let body_str = String::from_utf8(bytes).unwrap(); + assert_eq!(body_str, "invalid configuration to create network"); + } + + #[test] + fn request_paths_are_parsed_correctly() { + let path = "/api/v1/"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::new(), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network/"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network/some-subroute"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + subroute: Some(String::from("some-subroute")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network/some-subroute/"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + subroute: Some(String::from("some-subroute")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network/some-subroute/the/remaining/path"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + subroute: Some(String::from("some-subroute")), + remainder: Some(String::from("the/remaining/path")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network/some-subroute/the/remaining/path/"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + subroute: Some(String::from("some-subroute")), + remainder: Some(String::from("the/remaining/path")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + + let path = "/api/v1/some-route/some-network/some-subroute/the//remaining//path/"; + let path_parts = get_standardized_path_parts(path); + let expected = PathParts { + route: String::from("some-route"), + network: Some(String::from("some-network")), + subroute: Some(String::from("some-subroute")), + remainder: Some(String::from("the//remaining//path")), + ..Default::default() + }; + assert_eq!(path_parts, expected); + } - Ok(()) + #[tokio::test] + async fn request_mutation_should_create_valid_proxy_destination() { + let path = "/api/v1/some-route/some-network/stacks-node/the//remaining///path"; + let path_parts = get_standardized_path_parts(path); + let network = path_parts.network.unwrap(); + let subroute = path_parts.subroute.unwrap(); + let remainder = path_parts.remainder.unwrap(); + println!("{}", &remainder); + let proxy_data = get_proxy_data(&subroute); + let request_builder = Request::builder().uri("/").method("POST"); + let request: Request = request_builder.body(Body::empty()).unwrap(); + let request = mutate_request_for_proxy(request, &network, &remainder, proxy_data.unwrap()); + let actual_url = request.uri().to_string(); + let expected = format!( + "http://stacks-node-service.{}.svc.cluster.local:20443/{}", + network, &remainder + ); + println!("{expected}"); + assert_eq!(actual_url, expected); + } } diff --git a/templates/bitcoind-chain-coordinator-pod.template.yaml b/templates/bitcoind-chain-coordinator-pod.template.yaml index d5057b4..4ca9520 100644 --- a/templates/bitcoind-chain-coordinator-pod.template.yaml +++ b/templates/bitcoind-chain-coordinator-pod.template.yaml @@ -38,8 +38,8 @@ spec: configMapKeyRef: name: namespace-conf key: NAMESPACE - image: stacks-network - imagePullPolicy: Never + image: quay.io/hirosystems/stacks-network-orchestrator:latest + imagePullPolicy: Always name: chain-coordinator-container ports: - containerPort: 20445 diff --git a/templates/bitcoind-chain-coordinator-service.template.yaml b/templates/bitcoind-chain-coordinator-service.template.yaml index feee3aa..961474d 100644 --- a/templates/bitcoind-chain-coordinator-service.template.yaml +++ b/templates/bitcoind-chain-coordinator-service.template.yaml @@ -9,22 +9,17 @@ spec: port: 18444 protocol: TCP targetPort: 18444 - nodePort: 30000 - name: rpc port: 18443 protocol: TCP targetPort: 18443 - nodePort: 30001 - name: coordinator-in port: 20445 protocol: TCP targetPort: 20445 - nodePort: 30005 - name: coordinator-con port: 20446 protocol: TCP targetPort: 20446 - nodePort: 30006 selector: name: bitcoind-chain-coordinator - type: NodePort diff --git a/templates/initial-config/kind.yaml b/templates/initial-config/kind.yaml index fb1c46c..a884547 100644 --- a/templates/initial-config/kind.yaml +++ b/templates/initial-config/kind.yaml @@ -4,20 +4,4 @@ nodes: - role: control-plane extraPortMappings: - containerPort: 30000 - hostPort: 18444 - - containerPort: 30001 - hostPort: 18443 - - containerPort: 30002 - hostPort: 20444 - - containerPort: 30003 - hostPort: 20443 - - containerPort: 30005 - hostPort: 20445 - - containerPort: 30006 - hostPort: 20446 - - containerPort: 30007 - hostPort: 3999 - - containerPort: 30008 - hostPort: 5432 - - containerPort: 30009 - hostPort: 3700 \ No newline at end of file + hostPort: 8477 \ No newline at end of file diff --git a/templates/stacks-api-service.template.yaml b/templates/stacks-api-service.template.yaml index 25d6c3c..3a23341 100644 --- a/templates/stacks-api-service.template.yaml +++ b/templates/stacks-api-service.template.yaml @@ -9,17 +9,13 @@ spec: port: 3999 protocol: TCP targetPort: 3999 - nodePort: 30007 - name: postgres port: 5432 protocol: TCP targetPort: 5432 - nodePort: 30008 - name: eventport port: 3700 protocol: TCP targetPort: 3700 - nodePort: 30009 selector: name: stacks-api - type: NodePort diff --git a/templates/stacks-devnet-api.template.yaml b/templates/stacks-devnet-api.template.yaml new file mode 100644 index 0000000..31672c7 --- /dev/null +++ b/templates/stacks-devnet-api.template.yaml @@ -0,0 +1,78 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: devnet + labels: + name: devnet + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: stacks-devnet-api-service-account + namespace: devnet + +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: stacks-devnet-api-service-account +rules: + - apiGroups: [""] + # TODO: production version should not be able to create/delete namespaces (only get) + resources: ["pods", "services", "configmaps", "persistentvolumeclaims", "namespaces"] + verbs: ["get", "delete", "create"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: stacks-devnet-api-service-account +subjects: + - kind: ServiceAccount + name: stacks-devnet-api-service-account + namespace: devnet +roleRef: + kind: ClusterRole + name: stacks-devnet-api-service-account + apiGroup: rbac.authorization.k8s.io + +--- + +apiVersion: v1 +kind: Pod +metadata: + labels: + name: stacks-devnet-api + name: stacks-devnet-api + namespace: devnet +spec: + serviceAccountName: stacks-devnet-api-service-account + containers: + - command: + - ./stacks-devnet-api + name: stacks-devnet-api-container + image: quay.io/hirosystems/stacks-devnet-api:latest + imagePullPolicy: Always + ports: + - containerPort: 8478 + name: api + protocol: TCP + +--- +apiVersion: v1 +kind: Service +metadata: + name: stacks-devnet-api-service + namespace: devnet +spec: + ports: + - name: api + port: 8477 + protocol: TCP + targetPort: 8477 + nodePort: 30000 + selector: + name: stacks-devnet-api + type: NodePort + diff --git a/templates/stacks-node-service.template.yaml b/templates/stacks-node-service.template.yaml index 75d9290..190b98c 100644 --- a/templates/stacks-node-service.template.yaml +++ b/templates/stacks-node-service.template.yaml @@ -8,11 +8,8 @@ spec: - name: p2p port: 20444 protocol: TCP - nodePort: 30002 - name: rpc port: 20443 protocol: TCP - nodePort: 30003 selector: name: stacks-node - type: NodePort