diff --git a/src/cli/allow_ip.rs b/src/cli/allow_ip.rs new file mode 100644 index 00000000..7fbb86f2 --- /dev/null +++ b/src/cli/allow_ip.rs @@ -0,0 +1,149 @@ +use crate::cli::util::{cluster_from_conn_str, find_org_id, find_project_id}; +use crate::cli::{client_error_to_shell_error, no_active_cluster_error}; +use crate::state::State; +use log::info; +use nu_engine::CallExt; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{Category, PipelineData, ShellError, Signature, SyntaxShape, Value}; +use std::ops::Add; +use std::sync::{Arc, Mutex}; +use tokio::time::Instant; + +#[derive(Clone)] +pub struct AllowIP { + state: Arc>, +} + +impl crate::cli::AllowIP { + pub fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl Command for crate::cli::AllowIP { + fn name(&self) -> &str { + "allow ip" + } + + fn signature(&self) -> Signature { + Signature::build("allow ip") + .category(Category::Custom("couchbase".to_string())) + .optional( + "address", + SyntaxShape::String, + "ip address to allow access to the cluster", + ) + } + + fn usage(&self) -> &str { + "Adds IP address to allowlist on a Capella cluster" + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + allow_ip(self.state.clone(), engine_state, stack, call, input) + } +} + +fn allow_ip( + state: Arc>, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, +) -> Result { + let span = call.head; + let ctrl_c = engine_state.ctrlc.as_ref().unwrap().clone(); + let guard = state.lock().unwrap(); + + let ip_address = match input.into_value(span)? { + Value::String { val, .. } => format_ip_address(val), + Value::Nothing { .. } => { + if let Some(address) = call.opt(engine_state, stack, 0)? { + format_ip_address(address) + } else { + return Err(ShellError::GenericError { + error: "No IP address provided".to_string(), + msg: "".to_string(), + span: None, + help: Some("Provide IP as positional parameter or piped input".into()), + inner: vec![], + }); + } + } + _ => { + return Err(ShellError::GenericError { + error: "IP address must be a string".to_string(), + msg: "".to_string(), + span: None, + help: None, + inner: vec![], + }) + } + }; + + let active_cluster = match guard.active_cluster() { + Some(c) => c, + None => { + return Err(no_active_cluster_error(span)); + } + }; + let org = if let Some(cluster_org) = active_cluster.capella_org() { + guard.get_capella_org(cluster_org) + } else { + guard.active_capella_org() + }?; + + let client = org.client(); + let deadline = Instant::now().add(org.timeout()); + + let org_id = find_org_id(ctrl_c.clone(), &client, deadline, span)?; + + let project_id = find_project_id( + ctrl_c.clone(), + guard.active_project().unwrap(), + &client, + deadline, + span, + org_id.clone(), + )?; + + let json_cluster = cluster_from_conn_str( + active_cluster.display_name().unwrap_or("".to_string()), + ctrl_c.clone(), + active_cluster.hostnames().clone(), + &client, + deadline, + span, + org_id.clone(), + project_id.clone(), + )?; + + client + .allow_ip_address( + org_id, + project_id, + json_cluster.id(), + ip_address, + deadline, + ctrl_c, + ) + .map_err(|e| client_error_to_shell_error(e, span))?; + + Ok(PipelineData::empty()) +} + +fn format_ip_address(ip_address: String) -> String { + if !ip_address.contains('/') { + info!("IP address supplied without a subnet mask, defaulting to '/32'"); + format!("{}/32", ip_address) + } else { + ip_address + } +} diff --git a/src/cli/credentials_create.rs b/src/cli/credentials_create.rs index f199a18b..d9960aa1 100644 --- a/src/cli/credentials_create.rs +++ b/src/cli/credentials_create.rs @@ -31,7 +31,7 @@ impl Command for crate::cli::CredentialsCreate { } fn usage(&self) -> &str { - "Creates a new cluster on the active Capella organization" + "Creates credentials on a Capella cluster" } fn run( @@ -94,7 +94,7 @@ fn credentials_create( project_id.clone(), )?; - if json_cluster.state() != "healthy".to_string() { + if json_cluster.state() != "healthy" { return Err(ShellError::GenericError { error: "Cannot create credentials until cluster state is healthy".to_string(), msg: "".to_string(), @@ -131,7 +131,7 @@ fn credentials_create( } }; - let payload = CredentialsCreateRequest::new(name.clone(), password.clone().into()); + let payload = CredentialsCreateRequest::new(name.clone(), password.clone()); client .create_credentials( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0d3d8f41..f8961dcc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,4 @@ +mod allow_ip; mod analytics; mod analytics_buckets; mod analytics_datasets; @@ -83,6 +84,7 @@ mod vector_enrich_text; mod vector_search; mod version; +pub use allow_ip::AllowIP; pub use analytics::Analytics; pub use analytics_buckets::AnalyticsBuckets; pub use analytics_datasets::AnalyticsDatasets; diff --git a/src/client/cloud.rs b/src/client/cloud.rs index 8c9d2e59..2c24a533 100644 --- a/src/client/cloud.rs +++ b/src/client/cloud.rs @@ -540,10 +540,42 @@ impl CapellaClient { }), } } + + pub fn allow_ip_address( + &self, + org_id: String, + project_id: String, + cluster_id: String, + address: String, + deadline: Instant, + ctrl_c: Arc, + ) -> Result<(), ClientError> { + let request = CapellaRequest::AllowIPAddress { + org_id, + project_id, + cluster_id, + payload: format!("{{\"cidr\": \"{}\"}}", address.clone()), + }; + let response = self.capella_request(request, deadline, ctrl_c)?; + + match response.status() { + 201 => Ok(()), + _ => Err(ClientError::RequestFailed { + reason: Some(response.content().into()), + key: None, + }), + } + } } #[allow(dead_code)] pub enum CapellaRequest { + AllowIPAddress { + org_id: String, + project_id: String, + cluster_id: String, + payload: String, + }, CreateAllowListEntry { cluster_id: String, payload: String, @@ -675,6 +707,17 @@ pub enum CapellaRequest { impl CapellaRequest { pub fn path(&self) -> String { match self { + Self::AllowIPAddress { + org_id, + project_id, + cluster_id, + .. + } => { + format!( + "/v4/organizations/{}/projects/{}/clusters/{}/allowedcidrs", + org_id, project_id, cluster_id + ) + } Self::CreateAllowListEntry { cluster_id, .. } => { format!("/v2/clusters/{}/allowlist", cluster_id) } @@ -849,6 +892,7 @@ impl CapellaRequest { pub fn verb(&self) -> HttpVerb { match self { + Self::AllowIPAddress { .. } => HttpVerb::Post, Self::CreateAllowListEntry { .. } => HttpVerb::Post, Self::CreateBucket { .. } => HttpVerb::Post, Self::CreateBucketV4 { .. } => HttpVerb::Post, @@ -884,6 +928,7 @@ impl CapellaRequest { pub fn payload(&self) -> Option> { match self { + Self::AllowIPAddress { payload, .. } => Some(payload.as_bytes().into()), Self::CreateAllowListEntry { payload, .. } => Some(payload.as_bytes().into()), Self::CreateBucket { payload, .. } => Some(payload.as_bytes().into()), Self::CreateBucketV4 { payload, .. } => Some(payload.as_bytes().into()), diff --git a/src/main.rs b/src/main.rs index de8f8203..c15451d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -704,6 +704,7 @@ fn make_state( fn merge_couchbase_delta(context: &mut EngineState, state: Arc>) { let delta = { let mut working_set = StateWorkingSet::new(context); + working_set.add_decl(Box::new(AllowIP::new(state.clone()))); working_set.add_decl(Box::new(Analytics::new(state.clone()))); working_set.add_decl(Box::new(AnalyticsBuckets::new(state.clone()))); working_set.add_decl(Box::new(AnalyticsDatasets::new(state.clone())));