feat(consul): add Consul full-stack workbench
This commit is contained in:
148 files changed
+24032
-378
No files matched your search
@@ -103,6 +103,12 @@ pub struct KvKeyMetadata {
|
||||
pub ephemeral_owner: Option<i64>,
|
||||
pub data_length: Option<u64>,
|
||||
pub num_children: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flags: Option<KvInt64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lock_index: Option<KvInt64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -138,6 +144,8 @@ pub struct KvListPrefixResponse {
|
||||
pub keys: Vec<KvKeySummary>,
|
||||
pub continuation: Option<String>,
|
||||
pub revision: Option<KvInt64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filtered_by_acls: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -205,6 +213,8 @@ pub struct KvPutOptions {
|
||||
pub expected_mod_revision: Option<KvInt64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expected_create_revision: Option<KvInt64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flags: Option<KvInt64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -118,6 +118,7 @@ pub enum PoolKind {
|
||||
MessageQueue,
|
||||
/// Nacos admin connection marker.
|
||||
Nacos,
|
||||
Consul(crate::consul::ConsulClient),
|
||||
/// MQTT broker connection with an active client.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
Mqtt(Arc<super::mqtt::client::MqttClient>),
|
||||
@@ -2039,6 +2040,17 @@ impl AppState {
|
||||
adapter.test_connection().await?;
|
||||
PoolKind::Nacos
|
||||
}
|
||||
DatabaseType::Consul => {
|
||||
let mut consul_config = crate::consul::ConsulConfig::from_connection(&db_config)?;
|
||||
let original_host = consul_config.base_url.host_str().unwrap_or_default();
|
||||
let original_port = consul_config.base_url.port_or_known_default().unwrap_or(db_config.port);
|
||||
if host != original_host || port != original_port {
|
||||
consul_config = consul_config.with_connect_override(&host, port);
|
||||
}
|
||||
let client = crate::consul::ConsulClient::new(consul_config).await?;
|
||||
client.probe().await?;
|
||||
PoolKind::Consul(client)
|
||||
}
|
||||
agent_connection_pool_database_type!() => {
|
||||
let connect_params = agent_connect_params_with_role(
|
||||
&db_config,
|
||||
@@ -3077,7 +3089,8 @@ impl AppState {
|
||||
| PoolKind::DuckDbWorker(_)
|
||||
| PoolKind::ExternalDriver { .. }
|
||||
| PoolKind::MessageQueue
|
||||
| PoolKind::Nacos => false,
|
||||
| PoolKind::Nacos
|
||||
| PoolKind::Consul(_) => false,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
PoolKind::Mqtt(_) => false,
|
||||
}
|
||||
@@ -3969,7 +3982,8 @@ impl AppState {
|
||||
| PoolKind::DuckDbWorker(_)
|
||||
| PoolKind::ExternalDriver { .. }
|
||||
| PoolKind::MessageQueue
|
||||
| PoolKind::Nacos => true,
|
||||
| PoolKind::Nacos
|
||||
| PoolKind::Consul(_) => true,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
PoolKind::Mqtt(_) => true,
|
||||
PoolKind::Redis(_) => unreachable!("Redis handled separately"),
|
||||
@@ -4396,6 +4410,8 @@ fn connection_remote_endpoint(config: &ConnectionConfig) -> (String, u16) {
|
||||
parse_mqtt_broker_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
|
||||
} else if config.db_type == DatabaseType::Nacos {
|
||||
parse_nacos_server_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
|
||||
} else if config.db_type == DatabaseType::Consul {
|
||||
parse_consul_server_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port))
|
||||
} else {
|
||||
(config.host.clone(), config.port)
|
||||
}
|
||||
@@ -4467,6 +4483,21 @@ fn parse_nacos_server_host_port(config: &ConnectionConfig) -> Option<(String, u1
|
||||
Some((host, port))
|
||||
}
|
||||
|
||||
fn parse_consul_server_host_port(config: &ConnectionConfig) -> Option<(String, u16)> {
|
||||
let value = config
|
||||
.external_config
|
||||
.as_ref()?
|
||||
.get("serverAddr")
|
||||
.or_else(|| config.external_config.as_ref()?.get("server_addr"))?
|
||||
.as_str()?
|
||||
.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let url = reqwest::Url::parse(value).ok()?;
|
||||
Some((url.host_str()?.to_string(), url.port_or_known_default()?))
|
||||
}
|
||||
|
||||
fn parse_mqtt_broker_host_port(config: &ConnectionConfig) -> Option<(String, u16)> {
|
||||
let value = config
|
||||
.external_config
|
||||
@@ -4618,6 +4649,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
|
||||
}
|
||||
PoolKind::MessageQueue => PoolKind::MessageQueue,
|
||||
PoolKind::Nacos => PoolKind::Nacos,
|
||||
PoolKind::Consul(client) => PoolKind::Consul(client.clone()),
|
||||
#[cfg(feature = "mq-admin")]
|
||||
PoolKind::Mqtt(client) => PoolKind::Mqtt(Arc::clone(client)),
|
||||
PoolKind::Redis(_) => panic!("clone_pool_kind not supported for Redis — handled separately"),
|
||||
@@ -4679,6 +4711,7 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
|
||||
}
|
||||
PoolKind::MessageQueue => {}
|
||||
PoolKind::Nacos => {}
|
||||
PoolKind::Consul(_) => {}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
PoolKind::Mqtt(client) => {
|
||||
// 发送 DISCONNECT 并等待事件循环任务结束
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
use std::fmt;
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ConsulClient};
|
||||
use super::response::{decode_json_response, ConsulResponseMetadata};
|
||||
|
||||
/// Sensitive Consul value. Deliberately does not implement `Debug`.
|
||||
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ConsulSecret(String);
|
||||
|
||||
impl ConsulSecret {
|
||||
pub fn expose_once(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ConsulAclKind {
|
||||
Token,
|
||||
Policy,
|
||||
Role,
|
||||
AuthMethod,
|
||||
BindingRule,
|
||||
TemplatedPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclLink {
|
||||
#[serde(rename = "ID", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclToken {
|
||||
#[serde(rename = "AccessorID", default)]
|
||||
pub accessor_id: String,
|
||||
#[serde(rename = "SecretID", default, skip_serializing_if = "Option::is_none")]
|
||||
pub secret_id: Option<ConsulSecret>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub local: bool,
|
||||
#[serde(default)]
|
||||
pub auth_method: String,
|
||||
#[serde(default)]
|
||||
pub policies: Vec<ConsulAclLink>,
|
||||
#[serde(default)]
|
||||
pub roles: Vec<ConsulAclLink>,
|
||||
#[serde(default)]
|
||||
pub service_identities: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub node_identities: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub templated_policies: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclPolicy {
|
||||
#[serde(rename = "ID", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub rules: String,
|
||||
#[serde(default)]
|
||||
pub datacenters: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclRole {
|
||||
#[serde(rename = "ID", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub policies: Vec<ConsulAclLink>,
|
||||
#[serde(default)]
|
||||
pub service_identities: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub node_identities: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub templated_policies: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclAuthMethod {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(rename = "Type", default)]
|
||||
pub method_type: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(rename = "MaxTokenTTL", default)]
|
||||
pub max_token_ttl: String,
|
||||
#[serde(default)]
|
||||
pub token_locality: String,
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ConsulAclAuthMethod {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ConsulAclAuthMethod")
|
||||
.field("name", &self.name)
|
||||
.field("method_type", &self.method_type)
|
||||
.field("display_name", &self.display_name)
|
||||
.field("description", &self.description)
|
||||
.field("max_token_ttl", &self.max_token_ttl)
|
||||
.field("token_locality", &self.token_locality)
|
||||
.field("config", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclBindingRule {
|
||||
#[serde(rename = "ID", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub auth_method: String,
|
||||
#[serde(default)]
|
||||
pub selector: String,
|
||||
#[serde(default)]
|
||||
pub bind_type: String,
|
||||
#[serde(default)]
|
||||
pub bind_name: String,
|
||||
#[serde(default)]
|
||||
pub bind_vars: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclTemplatedPolicy {
|
||||
#[serde(default)]
|
||||
pub template_name: String,
|
||||
#[serde(default)]
|
||||
pub schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "items", rename_all = "camelCase")]
|
||||
pub enum ConsulAclList {
|
||||
Token(Vec<ConsulAclToken>),
|
||||
Policy(Vec<ConsulAclPolicy>),
|
||||
Role(Vec<ConsulAclRole>),
|
||||
AuthMethod(Vec<ConsulAclAuthMethod>),
|
||||
BindingRule(Vec<ConsulAclBindingRule>),
|
||||
TemplatedPolicy(Vec<ConsulAclTemplatedPolicy>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
|
||||
pub enum ConsulAclItem {
|
||||
Token(ConsulAclToken),
|
||||
Policy(ConsulAclPolicy),
|
||||
Role(ConsulAclRole),
|
||||
AuthMethod(ConsulAclAuthMethod),
|
||||
BindingRule(ConsulAclBindingRule),
|
||||
TemplatedPolicy(ConsulAclTemplatedPolicy),
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
|
||||
pub enum ConsulAclWrite {
|
||||
Token(ConsulAclToken),
|
||||
Policy(ConsulAclPolicy),
|
||||
Role(ConsulAclRole),
|
||||
AuthMethod(ConsulAclAuthMethod),
|
||||
BindingRule(ConsulAclBindingRule),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulAclReferences {
|
||||
pub token_accessor_ids: Vec<String>,
|
||||
pub role_ids: Vec<String>,
|
||||
pub binding_rule_ids: Vec<String>,
|
||||
pub complete: bool,
|
||||
pub filtered_by_acls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAclTokenClone {
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn consul_acl_token_self_core(state: &AppState, connection_id: &str) -> Result<ConsulAclToken, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/acl/token/self")?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read current ACL token").await.map(without_token_secret)
|
||||
}
|
||||
|
||||
pub async fn consul_acl_token_clone_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
accessor_id: &str,
|
||||
request: ConsulAclTokenClone,
|
||||
) -> Result<ConsulAclToken, String> {
|
||||
super::ensure_writable(state, connection_id, "ACL token clone").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let accessor_id = percent_encoding::utf8_percent_encode(accessor_id, percent_encoding::NON_ALPHANUMERIC);
|
||||
let url = client.api_url(&format!("/v1/acl/token/{accessor_id}/clone"))?;
|
||||
client.request_json(Method::PUT, url, Some(&request), false, "clone ACL token").await
|
||||
}
|
||||
|
||||
pub async fn consul_acl_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulAclKind,
|
||||
) -> Result<ConsulAclList, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
match kind {
|
||||
ConsulAclKind::Token => Ok(ConsulAclList::Token(
|
||||
list::<ConsulAclToken>(&client, "/v1/acl/tokens", "list ACL tokens")
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(without_token_secret)
|
||||
.collect(),
|
||||
)),
|
||||
ConsulAclKind::Policy => {
|
||||
Ok(ConsulAclList::Policy(list(&client, "/v1/acl/policies", "list ACL policies").await?))
|
||||
}
|
||||
ConsulAclKind::Role => Ok(ConsulAclList::Role(list(&client, "/v1/acl/roles", "list ACL roles").await?)),
|
||||
ConsulAclKind::AuthMethod => Ok(ConsulAclList::AuthMethod(
|
||||
list::<ConsulAclAuthMethod>(&client, "/v1/acl/auth-methods", "list ACL auth methods")
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(without_auth_method_secrets)
|
||||
.collect(),
|
||||
)),
|
||||
ConsulAclKind::BindingRule => {
|
||||
Ok(ConsulAclList::BindingRule(list(&client, "/v1/acl/binding-rules", "list ACL binding rules").await?))
|
||||
}
|
||||
ConsulAclKind::TemplatedPolicy => Ok(ConsulAclList::TemplatedPolicy(
|
||||
list(&client, "/v1/acl/templated-policies", "list ACL templated policies").await?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_acl_get_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulAclKind,
|
||||
id: &str,
|
||||
) -> Result<ConsulAclItem, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
if kind == ConsulAclKind::TemplatedPolicy {
|
||||
return list::<ConsulAclTemplatedPolicy>(&client, "/v1/acl/templated-policies", "list ACL templated policies")
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|policy| policy.template_name == id)
|
||||
.map(ConsulAclItem::TemplatedPolicy)
|
||||
.ok_or_else(|| "CONSUL_ACL_NOT_FOUND: Templated policy not found".to_string());
|
||||
}
|
||||
let url = client.api_url(&item_path(kind, id)?)?;
|
||||
match kind {
|
||||
ConsulAclKind::Token => Ok(ConsulAclItem::Token(without_token_secret(
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read ACL token").await?,
|
||||
))),
|
||||
ConsulAclKind::Policy => Ok(ConsulAclItem::Policy(
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read ACL policy").await?,
|
||||
)),
|
||||
ConsulAclKind::Role => {
|
||||
Ok(ConsulAclItem::Role(client.request_json(Method::GET, url, None::<&()>, true, "read ACL role").await?))
|
||||
}
|
||||
ConsulAclKind::AuthMethod => Ok(ConsulAclItem::AuthMethod(without_auth_method_secrets(
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read ACL auth method").await?,
|
||||
))),
|
||||
ConsulAclKind::BindingRule => Ok(ConsulAclItem::BindingRule(
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read ACL binding rule").await?,
|
||||
)),
|
||||
ConsulAclKind::TemplatedPolicy => unreachable!("templated policy reads are handled by the collection"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_acl_apply_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: Option<&str>,
|
||||
value: ConsulAclWrite,
|
||||
) -> Result<ConsulAclItem, String> {
|
||||
super::ensure_writable(state, connection_id, "ACL write").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
match value {
|
||||
ConsulAclWrite::Token(item) => Ok(ConsulAclItem::Token(apply(&client, ConsulAclKind::Token, id, &item).await?)),
|
||||
ConsulAclWrite::Policy(item) => {
|
||||
Ok(ConsulAclItem::Policy(apply(&client, ConsulAclKind::Policy, id, &item).await?))
|
||||
}
|
||||
ConsulAclWrite::Role(item) => Ok(ConsulAclItem::Role(apply(&client, ConsulAclKind::Role, id, &item).await?)),
|
||||
ConsulAclWrite::AuthMethod(mut item) => {
|
||||
if let Some(id) = id {
|
||||
let url = client.api_url(&item_path(ConsulAclKind::AuthMethod, id)?)?;
|
||||
let existing: ConsulAclAuthMethod = client
|
||||
.request_json(Method::GET, url, None::<&()>, true, "read ACL auth method before update")
|
||||
.await?;
|
||||
merge_missing_sensitive_config(&mut item.config, &existing.config);
|
||||
}
|
||||
Ok(ConsulAclItem::AuthMethod(without_auth_method_secrets(
|
||||
apply(&client, ConsulAclKind::AuthMethod, id, &item).await?,
|
||||
)))
|
||||
}
|
||||
ConsulAclWrite::BindingRule(item) => {
|
||||
Ok(ConsulAclItem::BindingRule(apply(&client, ConsulAclKind::BindingRule, id, &item).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_acl_delete_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulAclKind,
|
||||
id: &str,
|
||||
) -> Result<ConsulAclReferences, String> {
|
||||
super::ensure_writable(state, connection_id, "ACL delete").await?;
|
||||
let references = consul_acl_references_core(state, connection_id, kind, id).await?;
|
||||
if !references.complete {
|
||||
return Err("CONSUL_IMPACT_INCOMPLETE: ACL references could not be enumerated completely".to_string());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&item_path(kind, id)?)?;
|
||||
client.send_json(Method::DELETE, url, None::<&()>, false, "delete ACL resource").await?;
|
||||
Ok(references)
|
||||
}
|
||||
|
||||
pub async fn consul_acl_references_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulAclKind,
|
||||
id: &str,
|
||||
) -> Result<ConsulAclReferences, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let tokens = if matches!(kind, ConsulAclKind::Policy | ConsulAclKind::Role | ConsulAclKind::AuthMethod) {
|
||||
list_with_meta::<ConsulAclToken>(&client, "/v1/acl/tokens", "list ACL token references").await
|
||||
} else {
|
||||
Ok((Vec::new(), false))
|
||||
};
|
||||
let roles = if kind == ConsulAclKind::Policy {
|
||||
list_with_meta::<ConsulAclRole>(&client, "/v1/acl/roles", "list ACL role references").await
|
||||
} else {
|
||||
Ok((Vec::new(), false))
|
||||
};
|
||||
let binding_rules = if kind == ConsulAclKind::AuthMethod {
|
||||
list_with_meta::<ConsulAclBindingRule>(&client, "/v1/acl/binding-rules", "list ACL binding rule references")
|
||||
.await
|
||||
} else {
|
||||
Ok((Vec::new(), false))
|
||||
};
|
||||
let complete = tokens.is_ok() && roles.is_ok() && binding_rules.is_ok();
|
||||
let (tokens, token_filtered) = tokens.unwrap_or_default();
|
||||
let (roles, role_filtered) = roles.unwrap_or_default();
|
||||
let (binding_rules, binding_rule_filtered) = binding_rules.unwrap_or_default();
|
||||
let mut result = ConsulAclReferences {
|
||||
complete: complete && !(token_filtered || role_filtered || binding_rule_filtered),
|
||||
filtered_by_acls: token_filtered || role_filtered || binding_rule_filtered,
|
||||
..Default::default()
|
||||
};
|
||||
match kind {
|
||||
ConsulAclKind::Policy => {
|
||||
result.token_accessor_ids = tokens
|
||||
.iter()
|
||||
.filter(|token| token.policies.iter().any(|link| link.id == id || link.name == id))
|
||||
.map(|token| token.accessor_id.clone())
|
||||
.collect();
|
||||
result.role_ids = roles
|
||||
.iter()
|
||||
.filter(|role| role.policies.iter().any(|link| link.id == id || link.name == id))
|
||||
.map(|role| role.id.clone())
|
||||
.collect();
|
||||
}
|
||||
ConsulAclKind::Role => {
|
||||
result.token_accessor_ids = tokens
|
||||
.iter()
|
||||
.filter(|token| token.roles.iter().any(|link| link.id == id || link.name == id))
|
||||
.map(|token| token.accessor_id.clone())
|
||||
.collect()
|
||||
}
|
||||
ConsulAclKind::AuthMethod => {
|
||||
result.token_accessor_ids =
|
||||
tokens.iter().filter(|token| token.auth_method == id).map(|token| token.accessor_id.clone()).collect();
|
||||
result.binding_rule_ids =
|
||||
binding_rules.iter().filter(|rule| rule.auth_method == id).map(|rule| rule.id.clone()).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn list<T: serde::de::DeserializeOwned>(
|
||||
client: &ConsulClient,
|
||||
path: &str,
|
||||
action: &str,
|
||||
) -> Result<Vec<T>, String> {
|
||||
let url = client.api_url(path)?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, action).await
|
||||
}
|
||||
|
||||
async fn list_with_meta<T: serde::de::DeserializeOwned>(
|
||||
client: &ConsulClient,
|
||||
path: &str,
|
||||
action: &str,
|
||||
) -> Result<(Vec<T>, bool), String> {
|
||||
let url = client.api_url(path)?;
|
||||
let response = client.send_json(Method::GET, url, None::<&()>, true, action).await?;
|
||||
let filtered = ConsulResponseMetadata::from_response(&response).filtered_by_acls.unwrap_or(false);
|
||||
let items = decode_json_response(response, action).await?;
|
||||
Ok((items, filtered))
|
||||
}
|
||||
|
||||
async fn apply<T: Serialize + serde::de::DeserializeOwned>(
|
||||
client: &ConsulClient,
|
||||
kind: ConsulAclKind,
|
||||
id: Option<&str>,
|
||||
item: &T,
|
||||
) -> Result<T, String> {
|
||||
let (method, path) = if let Some(id) = id {
|
||||
(Method::PUT, item_path(kind, id)?)
|
||||
} else {
|
||||
(Method::PUT, collection_create_path(kind)?.to_string())
|
||||
};
|
||||
let url = client.api_url(&path)?;
|
||||
client.request_json(method, url, Some(item), false, "write ACL resource").await
|
||||
}
|
||||
|
||||
fn without_token_secret(mut token: ConsulAclToken) -> ConsulAclToken {
|
||||
token.secret_id = None;
|
||||
token
|
||||
}
|
||||
|
||||
fn without_auth_method_secrets(mut method: ConsulAclAuthMethod) -> ConsulAclAuthMethod {
|
||||
redact_sensitive_config(&mut method.config);
|
||||
method
|
||||
}
|
||||
|
||||
fn redact_sensitive_config(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
object.retain(|key, _| !is_sensitive_config_key(key));
|
||||
for value in object.values_mut() {
|
||||
redact_sensitive_config(value);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
redact_sensitive_config(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_config_key(key: &str) -> bool {
|
||||
let key = key.to_ascii_lowercase();
|
||||
key.contains("secret")
|
||||
|| key.contains("password")
|
||||
|| key.contains("passphrase")
|
||||
|| key.contains("token")
|
||||
|| key == "jwt"
|
||||
|| key.ends_with("jwt")
|
||||
|| key == "bootstrap"
|
||||
}
|
||||
|
||||
fn merge_missing_sensitive_config(target: &mut serde_json::Value, existing: &serde_json::Value) {
|
||||
let (serde_json::Value::Object(target), serde_json::Value::Object(existing)) = (target, existing) else {
|
||||
return;
|
||||
};
|
||||
for (key, existing_value) in existing {
|
||||
if is_sensitive_config_key(key) {
|
||||
target.entry(key.clone()).or_insert_with(|| existing_value.clone());
|
||||
} else if let Some(target_value) = target.get_mut(key) {
|
||||
merge_missing_sensitive_config(target_value, existing_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collection_create_path(kind: ConsulAclKind) -> Result<&'static str, String> {
|
||||
match kind {
|
||||
ConsulAclKind::Token => Ok("/v1/acl/token"),
|
||||
ConsulAclKind::Policy => Ok("/v1/acl/policy"),
|
||||
ConsulAclKind::Role => Ok("/v1/acl/role"),
|
||||
ConsulAclKind::AuthMethod => Ok("/v1/acl/auth-method"),
|
||||
ConsulAclKind::BindingRule => Ok("/v1/acl/binding-rule"),
|
||||
ConsulAclKind::TemplatedPolicy => Err("CONSUL_UNSUPPORTED: templated policies are read-only".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn item_path(kind: ConsulAclKind, id: &str) -> Result<String, String> {
|
||||
let segment = percent_encoding::utf8_percent_encode(id, percent_encoding::NON_ALPHANUMERIC);
|
||||
Ok(format!("{}/{}", collection_create_path(kind)?, segment))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn acl_paths_are_safe() {
|
||||
assert_eq!(item_path(ConsulAclKind::Policy, "team/a").unwrap(), "/v1/acl/policy/team%2Fa");
|
||||
assert!(item_path(ConsulAclKind::TemplatedPolicy, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_token_reads_do_not_serialize_secret_id() {
|
||||
let token = without_token_secret(ConsulAclToken {
|
||||
accessor_id: "accessor".into(),
|
||||
secret_id: Some(ConsulSecret("connection-token".into())),
|
||||
..Default::default()
|
||||
});
|
||||
let serialized = serde_json::to_string(&token).unwrap();
|
||||
assert!(!serialized.contains("connection-token"));
|
||||
assert!(!serialized.contains("SecretID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_method_output_and_debug_redact_nested_secrets() {
|
||||
let method = without_auth_method_secrets(ConsulAclAuthMethod {
|
||||
name: "oidc".into(),
|
||||
config: serde_json::json!({
|
||||
"OIDCClientID": "public-client",
|
||||
"OIDCClientSecret": "top-secret",
|
||||
"ServiceAccountJWT": "kubernetes-jwt",
|
||||
"Nested": { "bearer_token": "nested-secret", "JWKSURL": "https://issuer.example/jwks" }
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
let debug = format!("{method:?}");
|
||||
assert!(!serialized.contains("top-secret"));
|
||||
assert!(!serialized.contains("nested-secret"));
|
||||
assert!(!serialized.contains("kubernetes-jwt"));
|
||||
assert!(serialized.contains("public-client"));
|
||||
assert!(!debug.contains("public-client"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_method_update_preserves_omitted_sensitive_config() {
|
||||
let existing = serde_json::json!({
|
||||
"Host": "https://kubernetes.example",
|
||||
"ServiceAccountJWT": "keep-me",
|
||||
"Nested": { "OIDCClientSecret": "also-keep", "Issuer": "old" }
|
||||
});
|
||||
let mut update = serde_json::json!({
|
||||
"Host": "https://new.example",
|
||||
"Nested": { "Issuer": "new" }
|
||||
});
|
||||
merge_missing_sensitive_config(&mut update, &existing);
|
||||
assert_eq!(update["ServiceAccountJWT"], "keep-me");
|
||||
assert_eq!(update["Nested"]["OIDCClientSecret"], "also-keep");
|
||||
assert_eq!(update["Nested"]["Issuer"], "new");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,617 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::{KvInt64, KvValue};
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
|
||||
use super::kv::{ConsulKvRecord, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES};
|
||||
use super::txn::{cas_operation, ConsulTxnRequest, MAX_TXN_OPERATIONS};
|
||||
|
||||
const CONSUL_BUNDLE_FORMAT: &str = "dbx-consul-kv-bundle";
|
||||
const CONSUL_BUNDLE_VERSION: u32 = 1;
|
||||
const MAX_VALUE_BYTES: usize = 512 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulExportScopeKind {
|
||||
Key,
|
||||
Prefix,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulExportRequest {
|
||||
pub path: String,
|
||||
pub kind: ConsulExportScopeKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulBundleScope {
|
||||
pub datacenter: String,
|
||||
pub namespace: String,
|
||||
pub partition: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulBundleEntry {
|
||||
pub key: String,
|
||||
pub value: KvValue,
|
||||
pub flags: KvInt64,
|
||||
pub create_index: Option<KvInt64>,
|
||||
pub modify_index: Option<KvInt64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulKvBundle {
|
||||
pub format: String,
|
||||
pub version: u32,
|
||||
pub exported_at_unix_ms: u64,
|
||||
pub prefix: String,
|
||||
pub scope_kind: ConsulExportScopeKind,
|
||||
pub source: ConsulBundleScope,
|
||||
pub entries: Vec<ConsulBundleEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulImportConflictPolicy {
|
||||
Abort,
|
||||
Skip,
|
||||
Cas,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulImportOperation {
|
||||
Create,
|
||||
Update,
|
||||
Unchanged,
|
||||
Skipped,
|
||||
Conflict,
|
||||
Locked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulImportPreviewRow {
|
||||
pub key: String,
|
||||
pub operation: ConsulImportOperation,
|
||||
pub expected_modify_index: Option<KvInt64>,
|
||||
pub target_session: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulImportPreview {
|
||||
pub preview_id: String,
|
||||
pub rows: Vec<ConsulImportPreviewRow>,
|
||||
pub can_apply: bool,
|
||||
pub creates: usize,
|
||||
pub updates: usize,
|
||||
pub unchanged: usize,
|
||||
pub skipped: usize,
|
||||
pub conflicts: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulImportRequest {
|
||||
pub bundle: ConsulKvBundle,
|
||||
pub policy: ConsulImportConflictPolicy,
|
||||
#[serde(default)]
|
||||
pub preview_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulImportOutcome {
|
||||
Succeeded,
|
||||
Conflicted,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulImportResultItem {
|
||||
pub key: String,
|
||||
pub outcome: ConsulImportOutcome,
|
||||
pub message: Option<String>,
|
||||
pub batch: Option<usize>,
|
||||
pub op_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulImportReport {
|
||||
pub items: Vec<ConsulImportResultItem>,
|
||||
pub succeeded: usize,
|
||||
pub conflicted: usize,
|
||||
pub skipped: usize,
|
||||
pub failed: usize,
|
||||
pub atomic: bool,
|
||||
}
|
||||
|
||||
pub async fn consul_export_bundle_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulExportRequest,
|
||||
) -> Result<ConsulKvBundle, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let entries = match request.kind {
|
||||
ConsulExportScopeKind::Key => {
|
||||
let value = client.get_key(&request.path).await?;
|
||||
if !value.found {
|
||||
return Err("CONSUL_KEY_NOT_FOUND: The Consul KV key no longer exists".to_string());
|
||||
}
|
||||
let metadata = value.metadata.unwrap_or_else(empty_metadata);
|
||||
vec![ConsulBundleEntry {
|
||||
key: value.key.unwrap_or(request.path.clone()),
|
||||
value: value.value.ok_or("Consul returned a KV key without a value")?,
|
||||
flags: metadata.flags.unwrap_or_else(|| KvInt64("0".to_string())),
|
||||
create_index: metadata.create_revision,
|
||||
modify_index: metadata.mod_revision,
|
||||
}]
|
||||
}
|
||||
ConsulExportScopeKind::Prefix => {
|
||||
let recursive =
|
||||
client.list_recursive(&request.path, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES).await?;
|
||||
if recursive.filtered_by_acls == Some(true) {
|
||||
return Err(
|
||||
"CONSUL_EXPORT_ACL_FILTERED: Export is blocked because ACL policies hide part of the prefix"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
recursive.entries.into_iter().map(bundle_entry_from_record).collect()
|
||||
}
|
||||
};
|
||||
Ok(ConsulKvBundle {
|
||||
format: CONSUL_BUNDLE_FORMAT.to_string(),
|
||||
version: CONSUL_BUNDLE_VERSION,
|
||||
exported_at_unix_ms: SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64,
|
||||
prefix: request.path,
|
||||
scope_kind: request.kind,
|
||||
source: ConsulBundleScope {
|
||||
datacenter: client.datacenter().to_string(),
|
||||
namespace: client.namespace().to_string(),
|
||||
partition: client.partition().to_string(),
|
||||
},
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn consul_import_preview_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulImportRequest,
|
||||
) -> Result<ConsulImportPreview, String> {
|
||||
validate_bundle(&request.bundle)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
build_preview(&client, connection_id, &request.bundle, request.policy).await
|
||||
}
|
||||
|
||||
pub async fn consul_import_execute_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulImportRequest,
|
||||
) -> Result<ConsulImportReport, String> {
|
||||
ensure_writable_core(state, connection_id, "import Consul KV bundle").await?;
|
||||
validate_bundle(&request.bundle)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let preview = build_preview(&client, connection_id, &request.bundle, request.policy).await?;
|
||||
if request.preview_id.as_deref() != Some(preview.preview_id.as_str()) {
|
||||
return Err(
|
||||
"CONSUL_IMPORT_PREVIEW_STALE: Target state changed after preview; refresh before importing".to_string()
|
||||
);
|
||||
}
|
||||
if !preview.can_apply {
|
||||
return Ok(report_from_blocked_preview(preview));
|
||||
}
|
||||
|
||||
let preview_by_key =
|
||||
preview.rows.into_iter().map(|row| (row.key.clone(), row)).collect::<std::collections::HashMap<_, _>>();
|
||||
let mut items = Vec::with_capacity(request.bundle.entries.len());
|
||||
let mut sources = request.bundle.entries;
|
||||
sources.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
let mut pending = Vec::new();
|
||||
for source in sources {
|
||||
let Some(row) = preview_by_key.get(&source.key) else {
|
||||
continue;
|
||||
};
|
||||
match row.operation {
|
||||
ConsulImportOperation::Unchanged | ConsulImportOperation::Skipped => {
|
||||
items.push(result_item(source.key, ConsulImportOutcome::Skipped, row.reason.clone(), None, None));
|
||||
}
|
||||
ConsulImportOperation::Conflict | ConsulImportOperation::Locked => {
|
||||
items.push(result_item(source.key, ConsulImportOutcome::Conflicted, row.reason.clone(), None, None));
|
||||
}
|
||||
ConsulImportOperation::Create | ConsulImportOperation::Update => {
|
||||
let index = row.expected_modify_index.clone().unwrap_or_else(|| KvInt64("0".to_string()));
|
||||
pending.push((source.key.clone(), cas_operation(source.key, source.value, source.flags, index)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let atomic = pending.len() <= MAX_TXN_OPERATIONS;
|
||||
for (batch_index, chunk) in pending.chunks(MAX_TXN_OPERATIONS).enumerate() {
|
||||
let result = client
|
||||
.txn(ConsulTxnRequest { operations: chunk.iter().map(|(_, operation)| operation.clone()).collect() })
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) if result.committed => {
|
||||
items.extend(chunk.iter().enumerate().map(|(op_index, (key, _))| {
|
||||
result_item(key.clone(), ConsulImportOutcome::Succeeded, None, Some(batch_index), Some(op_index))
|
||||
}));
|
||||
}
|
||||
Ok(result) => {
|
||||
for (op_index, (key, _)) in chunk.iter().enumerate() {
|
||||
let error = result.errors.iter().find(|error| error.op_index == op_index);
|
||||
items.push(result_item(
|
||||
key.clone(),
|
||||
if error.is_some() { ConsulImportOutcome::Conflicted } else { ConsulImportOutcome::Failed },
|
||||
Some(
|
||||
error
|
||||
.map(|error| error.message.clone())
|
||||
.unwrap_or_else(|| "Transaction batch was aborted".to_string()),
|
||||
),
|
||||
Some(batch_index),
|
||||
Some(op_index),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => items.extend(chunk.iter().enumerate().map(|(op_index, (key, _))| {
|
||||
result_item(
|
||||
key.clone(),
|
||||
ConsulImportOutcome::Failed,
|
||||
Some(error.clone()),
|
||||
Some(batch_index),
|
||||
Some(op_index),
|
||||
)
|
||||
})),
|
||||
}
|
||||
}
|
||||
Ok(summarize_report(items, atomic))
|
||||
}
|
||||
|
||||
async fn build_preview(
|
||||
client: &ConsulClient,
|
||||
connection_id: &str,
|
||||
bundle: &ConsulKvBundle,
|
||||
policy: ConsulImportConflictPolicy,
|
||||
) -> Result<ConsulImportPreview, String> {
|
||||
let mut rows = Vec::with_capacity(bundle.entries.len());
|
||||
for source in &bundle.entries {
|
||||
let target = client.get_key(&source.key).await?;
|
||||
let row = if !target.found {
|
||||
ConsulImportPreviewRow {
|
||||
key: source.key.clone(),
|
||||
operation: ConsulImportOperation::Create,
|
||||
expected_modify_index: None,
|
||||
target_session: None,
|
||||
reason: None,
|
||||
}
|
||||
} else {
|
||||
preview_existing(source, target, policy)
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
let creates = count_operation(&rows, ConsulImportOperation::Create);
|
||||
let updates = count_operation(&rows, ConsulImportOperation::Update);
|
||||
let unchanged = count_operation(&rows, ConsulImportOperation::Unchanged);
|
||||
let skipped = count_operation(&rows, ConsulImportOperation::Skipped);
|
||||
let conflicts =
|
||||
count_operation(&rows, ConsulImportOperation::Conflict) + count_operation(&rows, ConsulImportOperation::Locked);
|
||||
let can_apply = conflicts == 0 || policy != ConsulImportConflictPolicy::Abort;
|
||||
let preview_id = preview_id(&rows, bundle, policy, connection_id, client);
|
||||
Ok(ConsulImportPreview { preview_id, rows, can_apply, creates, updates, unchanged, skipped, conflicts })
|
||||
}
|
||||
|
||||
fn preview_existing(
|
||||
source: &ConsulBundleEntry,
|
||||
target: crate::agent_kv::KvGetResponse,
|
||||
policy: ConsulImportConflictPolicy,
|
||||
) -> ConsulImportPreviewRow {
|
||||
let metadata = target.metadata.unwrap_or_else(empty_metadata);
|
||||
let target_session = metadata.session.filter(|session| !session.is_empty());
|
||||
if target_session.is_some() {
|
||||
return ConsulImportPreviewRow {
|
||||
key: source.key.clone(),
|
||||
operation: ConsulImportOperation::Locked,
|
||||
expected_modify_index: metadata.mod_revision,
|
||||
target_session,
|
||||
reason: Some("Target key is held by a Consul Session".to_string()),
|
||||
};
|
||||
}
|
||||
let target_flags = metadata.flags.unwrap_or_else(|| KvInt64("0".to_string()));
|
||||
if target.value.as_ref() == Some(&source.value) && target_flags == source.flags {
|
||||
return ConsulImportPreviewRow {
|
||||
key: source.key.clone(),
|
||||
operation: ConsulImportOperation::Unchanged,
|
||||
expected_modify_index: metadata.mod_revision,
|
||||
target_session: None,
|
||||
reason: None,
|
||||
};
|
||||
}
|
||||
let (operation, reason) = match policy {
|
||||
ConsulImportConflictPolicy::Abort => {
|
||||
(ConsulImportOperation::Conflict, Some("Target key exists with a different value or Flags".to_string()))
|
||||
}
|
||||
ConsulImportConflictPolicy::Skip => {
|
||||
(ConsulImportOperation::Skipped, Some("Existing target key is skipped by the selected policy".to_string()))
|
||||
}
|
||||
ConsulImportConflictPolicy::Cas => (ConsulImportOperation::Update, None),
|
||||
};
|
||||
ConsulImportPreviewRow {
|
||||
key: source.key.clone(),
|
||||
operation,
|
||||
expected_modify_index: metadata.mod_revision,
|
||||
target_session: None,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_bundle(bundle: &ConsulKvBundle) -> Result<(), String> {
|
||||
if bundle.format != CONSUL_BUNDLE_FORMAT || bundle.version != CONSUL_BUNDLE_VERSION {
|
||||
return Err("CONSUL_BUNDLE_UNSUPPORTED: Expected DBX Consul KV bundle version 1".to_string());
|
||||
}
|
||||
if bundle.entries.len() > MAX_RECURSIVE_ENTRIES {
|
||||
return Err(format!("CONSUL_BUNDLE_TOO_LARGE: Bundle contains more than {MAX_RECURSIVE_ENTRIES} keys"));
|
||||
}
|
||||
let mut keys = HashSet::with_capacity(bundle.entries.len());
|
||||
let mut total_bytes = 0usize;
|
||||
for entry in &bundle.entries {
|
||||
if entry.key.is_empty() {
|
||||
return Err("CONSUL_BUNDLE_INVALID: Bundle contains an empty key".to_string());
|
||||
}
|
||||
if !keys.insert(&entry.key) {
|
||||
return Err(format!("CONSUL_BUNDLE_INVALID: Duplicate key in bundle: {}", entry.key));
|
||||
}
|
||||
entry
|
||||
.flags
|
||||
.as_str()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("CONSUL_BUNDLE_INVALID: Invalid Flags for key {}", entry.key))?;
|
||||
let value_bytes = value_size(&entry.value)?;
|
||||
if value_bytes > MAX_VALUE_BYTES {
|
||||
return Err(format!("CONSUL_VALUE_TOO_LARGE: Bundle value exceeds 512 KiB for key {}", entry.key));
|
||||
}
|
||||
total_bytes = total_bytes.saturating_add(value_bytes);
|
||||
if total_bytes > MAX_RECURSIVE_VALUE_BYTES {
|
||||
return Err("CONSUL_BUNDLE_TOO_LARGE: Bundle values exceed 32 MiB".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value_size(value: &KvValue) -> Result<usize, String> {
|
||||
match value.encoding {
|
||||
crate::agent_kv::KvValueEncoding::Utf8 => Ok(value.data.len()),
|
||||
crate::agent_kv::KvValueEncoding::Base64 => {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(value.data.chars().filter(|char| !char.is_whitespace()).collect::<String>())
|
||||
.map(|bytes| bytes.len())
|
||||
.map_err(|error| format!("CONSUL_BUNDLE_INVALID: Invalid Base64 value: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle_entry_from_record(record: ConsulKvRecord) -> ConsulBundleEntry {
|
||||
ConsulBundleEntry {
|
||||
key: record.key,
|
||||
value: record.value,
|
||||
flags: record.flags,
|
||||
create_index: Some(record.create_index),
|
||||
modify_index: Some(record.modify_index),
|
||||
}
|
||||
}
|
||||
|
||||
fn report_from_blocked_preview(preview: ConsulImportPreview) -> ConsulImportReport {
|
||||
let items = preview
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let outcome = if matches!(row.operation, ConsulImportOperation::Conflict | ConsulImportOperation::Locked) {
|
||||
ConsulImportOutcome::Conflicted
|
||||
} else {
|
||||
ConsulImportOutcome::Skipped
|
||||
};
|
||||
result_item(row.key, outcome, row.reason, None, None)
|
||||
})
|
||||
.collect();
|
||||
summarize_report(items, false)
|
||||
}
|
||||
|
||||
fn summarize_report(items: Vec<ConsulImportResultItem>, atomic: bool) -> ConsulImportReport {
|
||||
let succeeded = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Succeeded).count();
|
||||
let conflicted = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Conflicted).count();
|
||||
let skipped = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Skipped).count();
|
||||
let failed = items.iter().filter(|item| item.outcome == ConsulImportOutcome::Failed).count();
|
||||
ConsulImportReport { items, succeeded, conflicted, skipped, failed, atomic }
|
||||
}
|
||||
|
||||
fn result_item(
|
||||
key: String,
|
||||
outcome: ConsulImportOutcome,
|
||||
message: Option<String>,
|
||||
batch: Option<usize>,
|
||||
op_index: Option<usize>,
|
||||
) -> ConsulImportResultItem {
|
||||
ConsulImportResultItem { key, outcome, message, batch, op_index }
|
||||
}
|
||||
|
||||
fn preview_id(
|
||||
rows: &[ConsulImportPreviewRow],
|
||||
bundle: &ConsulKvBundle,
|
||||
policy: ConsulImportConflictPolicy,
|
||||
connection_id: &str,
|
||||
client: &ConsulClient,
|
||||
) -> String {
|
||||
preview_id_for_scope(rows, bundle, policy, connection_id, &client.scope())
|
||||
}
|
||||
|
||||
fn preview_id_for_scope(
|
||||
rows: &[ConsulImportPreviewRow],
|
||||
bundle: &ConsulKvBundle,
|
||||
policy: ConsulImportConflictPolicy,
|
||||
connection_id: &str,
|
||||
scope: &super::ConsulScope,
|
||||
) -> String {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
let canonical = serde_json::to_vec(&(connection_id, scope, bundle, policy, rows)).unwrap_or_default();
|
||||
for byte in canonical {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("consul-preview-{hash:016x}")
|
||||
}
|
||||
|
||||
fn count_operation(rows: &[ConsulImportPreviewRow], operation: ConsulImportOperation) -> usize {
|
||||
rows.iter().filter(|row| row.operation == operation).count()
|
||||
}
|
||||
|
||||
fn empty_metadata() -> crate::agent_kv::KvKeyMetadata {
|
||||
crate::agent_kv::KvKeyMetadata {
|
||||
create_revision: None,
|
||||
mod_revision: None,
|
||||
version: None,
|
||||
lease: None,
|
||||
ttl: None,
|
||||
value_size: None,
|
||||
czxid: None,
|
||||
mzxid: None,
|
||||
pzxid: None,
|
||||
ctime: None,
|
||||
mtime: None,
|
||||
cversion: None,
|
||||
aversion: None,
|
||||
ephemeral_owner: None,
|
||||
data_length: None,
|
||||
num_children: None,
|
||||
flags: None,
|
||||
lock_index: None,
|
||||
session: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent_kv::{KvGetResponse, KvKeyMetadata, KvValueEncoding};
|
||||
|
||||
#[test]
|
||||
fn bundle_validation_rejects_wrong_format_and_duplicate_keys() {
|
||||
let mut bundle = test_bundle();
|
||||
bundle.format = "wrong".to_string();
|
||||
assert!(validate_bundle(&bundle).unwrap_err().contains("CONSUL_BUNDLE_UNSUPPORTED"));
|
||||
|
||||
let mut bundle = test_bundle();
|
||||
bundle.entries.push(bundle.entries[0].clone());
|
||||
assert!(validate_bundle(&bundle).unwrap_err().contains("Duplicate key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_round_trips_binary_values_and_u64_flags_without_sensitive_fields() {
|
||||
let mut bundle = test_bundle();
|
||||
bundle.entries[0].value = KvValue { encoding: KvValueEncoding::Base64, data: "AP+A".into() };
|
||||
bundle.entries[0].flags = KvInt64(u64::MAX.to_string());
|
||||
let encoded = serde_json::to_string(&bundle).unwrap();
|
||||
for forbidden in ["SecretID", "X-Consul-Token", "session", "lockIndex"] {
|
||||
assert!(!encoded.contains(forbidden));
|
||||
}
|
||||
let decoded: ConsulKvBundle = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(decoded.entries[0].value.data, "AP+A");
|
||||
assert_eq!(decoded.entries[0].flags.as_str(), u64::MAX.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_key_policy_is_explicit() {
|
||||
let source = test_bundle().entries.remove(0);
|
||||
let target = || KvGetResponse {
|
||||
key: Some(source.key.clone()),
|
||||
key_bytes: None,
|
||||
found: true,
|
||||
value: Some(KvValue { encoding: KvValueEncoding::Utf8, data: "different".to_string() }),
|
||||
metadata: Some(KvKeyMetadata { mod_revision: Some(KvInt64("9".to_string())), ..empty_metadata() }),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
preview_existing(&source, target(), ConsulImportConflictPolicy::Abort).operation,
|
||||
ConsulImportOperation::Conflict
|
||||
);
|
||||
assert_eq!(
|
||||
preview_existing(&source, target(), ConsulImportConflictPolicy::Skip).operation,
|
||||
ConsulImportOperation::Skipped
|
||||
);
|
||||
assert_eq!(
|
||||
preview_existing(&source, target(), ConsulImportConflictPolicy::Cas).operation,
|
||||
ConsulImportOperation::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_id_binds_target_state_bundle_content_and_policy() {
|
||||
let scope = super::super::ConsulScope {
|
||||
datacenter: "dc1".into(),
|
||||
namespace: "team".into(),
|
||||
partition: "default".into(),
|
||||
};
|
||||
let bundle = test_bundle();
|
||||
let row = |index: &str| ConsulImportPreviewRow {
|
||||
key: "app/key".into(),
|
||||
operation: ConsulImportOperation::Update,
|
||||
expected_modify_index: Some(KvInt64(index.into())),
|
||||
target_session: None,
|
||||
reason: None,
|
||||
};
|
||||
|
||||
let initial = preview_id_for_scope(&[row("9")], &bundle, ConsulImportConflictPolicy::Cas, "target-a", &scope);
|
||||
assert_ne!(
|
||||
initial,
|
||||
preview_id_for_scope(&[row("10")], &bundle, ConsulImportConflictPolicy::Cas, "target-a", &scope)
|
||||
);
|
||||
assert_ne!(
|
||||
initial,
|
||||
preview_id_for_scope(&[row("9")], &bundle, ConsulImportConflictPolicy::Cas, "target-b", &scope)
|
||||
);
|
||||
|
||||
let mut changed_bundle = bundle.clone();
|
||||
changed_bundle.entries[0].value.data = "changed-after-preview".into();
|
||||
assert_ne!(
|
||||
initial,
|
||||
preview_id_for_scope(&[row("9")], &changed_bundle, ConsulImportConflictPolicy::Cas, "target-a", &scope)
|
||||
);
|
||||
assert_ne!(
|
||||
initial,
|
||||
preview_id_for_scope(&[row("9")], &bundle, ConsulImportConflictPolicy::Skip, "target-a", &scope)
|
||||
);
|
||||
}
|
||||
|
||||
fn test_bundle() -> ConsulKvBundle {
|
||||
ConsulKvBundle {
|
||||
format: CONSUL_BUNDLE_FORMAT.into(),
|
||||
version: CONSUL_BUNDLE_VERSION,
|
||||
exported_at_unix_ms: 1,
|
||||
prefix: "app/".into(),
|
||||
scope_kind: ConsulExportScopeKind::Prefix,
|
||||
source: ConsulBundleScope {
|
||||
datacenter: "dc1".into(),
|
||||
namespace: "team".into(),
|
||||
partition: "default".into(),
|
||||
},
|
||||
entries: vec![ConsulBundleEntry {
|
||||
key: "app/key".into(),
|
||||
value: KvValue { encoding: KvValueEncoding::Utf8, data: "value".into() },
|
||||
flags: KvInt64("0".into()),
|
||||
create_index: Some(KvInt64("1".into())),
|
||||
modify_index: Some(KvInt64("2".into())),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::{KvInt64, KvValue, KvValueEncoding};
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::catalog::{ConsulCatalogNode, ConsulCatalogServiceNode, ConsulNodeServices};
|
||||
use super::client::client_for_state;
|
||||
use super::health::{mark_maintenance, ConsulHealthCheck, ConsulServiceInstance};
|
||||
use super::kv::ConsulKvRecord;
|
||||
use super::response::{
|
||||
decode_json_body, ensure_success, is_consul_kv_not_found, read_bounded_response, ConsulResponseMetadata,
|
||||
};
|
||||
use super::types::ConsulKvEntry;
|
||||
|
||||
const MIN_QUERY_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulBlockingRequest {
|
||||
pub operation_id: String,
|
||||
#[serde(default)]
|
||||
pub generation: u64,
|
||||
pub key: String,
|
||||
pub prefix: bool,
|
||||
pub index: Option<KvInt64>,
|
||||
pub wait_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulBlockingResponse {
|
||||
pub entries: Vec<ConsulKvRecord>,
|
||||
pub metadata: ConsulResponseMetadata,
|
||||
pub changed: bool,
|
||||
pub timed_out: bool,
|
||||
pub index_reset: bool,
|
||||
}
|
||||
|
||||
/// A non-KV Consul endpoint with blocking-query support.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ConsulDomainWatchTarget {
|
||||
CatalogNodes,
|
||||
CatalogServices,
|
||||
CatalogServiceNodes { service: String },
|
||||
CatalogNodeServices { node: String },
|
||||
HealthNode { node: String },
|
||||
HealthServiceChecks { service: String },
|
||||
HealthServiceInstances { service: String, passing: Option<bool> },
|
||||
HealthState { state: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDomainWatchRequest {
|
||||
pub operation_id: String,
|
||||
#[serde(default)]
|
||||
pub generation: u64,
|
||||
pub target: ConsulDomainWatchTarget,
|
||||
pub index: Option<KvInt64>,
|
||||
pub wait_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDomainWatchResponse {
|
||||
pub items: ConsulDomainWatchItems,
|
||||
pub metadata: ConsulResponseMetadata,
|
||||
pub changed: bool,
|
||||
pub timed_out: bool,
|
||||
pub index_reset: bool,
|
||||
}
|
||||
|
||||
/// Each allowed watch target has an explicit response shape. The untagged
|
||||
/// representation preserves Consul's native JSON body for the UI transport.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ConsulDomainWatchItems {
|
||||
CatalogNodes(Vec<ConsulCatalogNode>),
|
||||
CatalogServices(BTreeMap<String, Vec<String>>),
|
||||
CatalogServiceNodes(Vec<ConsulCatalogServiceNode>),
|
||||
CatalogNodeServices(ConsulNodeServices),
|
||||
HealthNode(Vec<ConsulHealthCheck>),
|
||||
HealthServiceChecks(Vec<ConsulHealthCheck>),
|
||||
HealthServiceInstances(Vec<ConsulServiceInstance>),
|
||||
HealthState(Vec<ConsulHealthCheck>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlockingControl {
|
||||
cancelled: AtomicBool,
|
||||
}
|
||||
|
||||
struct BlockingRegistration(String);
|
||||
|
||||
impl Drop for BlockingRegistration {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut registry) = blocking_registry().lock() {
|
||||
registry.remove(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static BLOCKING: OnceLock<Mutex<HashMap<String, Arc<BlockingControl>>>> = OnceLock::new();
|
||||
|
||||
pub async fn consul_blocking_query_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulBlockingRequest,
|
||||
) -> Result<ConsulBlockingResponse, String> {
|
||||
validate_request(&request)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let scope = client.scope();
|
||||
let registry_key = format!(
|
||||
"{connection_id}\0{}\0{}\0{}\0{}\0{}",
|
||||
scope.datacenter, scope.partition, scope.namespace, request.generation, request.operation_id
|
||||
);
|
||||
let control = Arc::new(BlockingControl::default());
|
||||
{
|
||||
let mut registry =
|
||||
blocking_registry().lock().map_err(|_| "Consul blocking registry is unavailable".to_string())?;
|
||||
if registry.contains_key(®istry_key) {
|
||||
return Err("CONSUL_OPERATION_ALREADY_RUNNING: A blocking query with this operation ID is already running"
|
||||
.to_string());
|
||||
}
|
||||
registry.insert(registry_key.clone(), Arc::clone(&control));
|
||||
}
|
||||
let _registration = BlockingRegistration(registry_key);
|
||||
|
||||
let requested_index = request.index.as_ref().map(parse_index).transpose()?.unwrap_or(1).max(1);
|
||||
let started = Instant::now();
|
||||
let future = blocking_request(&client, &request, requested_index);
|
||||
tokio::pin!(future);
|
||||
let mut cancel_tick = tokio::time::interval(Duration::from_millis(50));
|
||||
let mut response = loop {
|
||||
tokio::select! {
|
||||
response = &mut future => break response?,
|
||||
_ = cancel_tick.tick() => {
|
||||
if control.cancelled.load(Ordering::Acquire) {
|
||||
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if control.cancelled.load(Ordering::Acquire) {
|
||||
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
|
||||
}
|
||||
|
||||
let returned_index = response.metadata.index.as_ref().map(parse_index).transpose()?.unwrap_or(0);
|
||||
response.index_reset = returned_index == 0 || returned_index < requested_index;
|
||||
response.changed = !response.index_reset && returned_index > requested_index;
|
||||
response.timed_out =
|
||||
!response.changed && started.elapsed() >= Duration::from_secs(request.wait_seconds.saturating_sub(1));
|
||||
if started.elapsed() < MIN_QUERY_INTERVAL {
|
||||
tokio::time::sleep(MIN_QUERY_INTERVAL - started.elapsed()).await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Runs a Catalog or Health blocking query under the same cancellable registry as KV watches.
|
||||
pub async fn consul_domain_watch_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulDomainWatchRequest,
|
||||
) -> Result<ConsulDomainWatchResponse, String> {
|
||||
validate_domain_watch_request(&request)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let scope = client.scope();
|
||||
let registry_key = format!(
|
||||
"{connection_id}\0{}\0{}\0{}\0{}\0{}",
|
||||
scope.datacenter, scope.partition, scope.namespace, request.generation, request.operation_id
|
||||
);
|
||||
let control = Arc::new(BlockingControl::default());
|
||||
{
|
||||
let mut registry =
|
||||
blocking_registry().lock().map_err(|_| "Consul blocking registry is unavailable".to_string())?;
|
||||
if registry.contains_key(®istry_key) {
|
||||
return Err("CONSUL_OPERATION_ALREADY_RUNNING: A blocking query with this operation ID is already running"
|
||||
.to_string());
|
||||
}
|
||||
registry.insert(registry_key.clone(), Arc::clone(&control));
|
||||
}
|
||||
let _registration = BlockingRegistration(registry_key);
|
||||
let requested_index = request.index.as_ref().map(parse_index).transpose()?.unwrap_or(1).max(1);
|
||||
let started = Instant::now();
|
||||
let future = domain_watch_request(&client, &request, requested_index);
|
||||
tokio::pin!(future);
|
||||
let mut cancel_tick = tokio::time::interval(Duration::from_millis(50));
|
||||
let mut response = loop {
|
||||
tokio::select! {
|
||||
response = &mut future => break response?,
|
||||
_ = cancel_tick.tick() => {
|
||||
if control.cancelled.load(Ordering::Acquire) {
|
||||
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if control.cancelled.load(Ordering::Acquire) {
|
||||
return Err("CONSUL_BLOCKING_CANCELLED: Consul blocking query was cancelled".to_string());
|
||||
}
|
||||
let returned_index = response.metadata.index.as_ref().map(parse_index).transpose()?.unwrap_or(0);
|
||||
response.index_reset = returned_index == 0 || returned_index < requested_index;
|
||||
response.changed = !response.index_reset && returned_index > requested_index;
|
||||
response.timed_out =
|
||||
!response.changed && started.elapsed() >= Duration::from_secs(request.wait_seconds.saturating_sub(1));
|
||||
if started.elapsed() < MIN_QUERY_INTERVAL {
|
||||
tokio::time::sleep(MIN_QUERY_INTERVAL - started.elapsed()).await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn consul_cancel_blocking_core(
|
||||
connection_id: &str,
|
||||
scope: &super::ConsulScope,
|
||||
generation: u64,
|
||||
operation_id: &str,
|
||||
) -> bool {
|
||||
let key = format!(
|
||||
"{connection_id}\0{}\0{}\0{}\0{generation}\0{operation_id}",
|
||||
scope.datacenter, scope.partition, scope.namespace
|
||||
);
|
||||
let control = blocking_registry().lock().ok().and_then(|registry| registry.get(&key).cloned());
|
||||
control.is_some_and(|control| {
|
||||
control.cancelled.store(true, Ordering::Release);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
async fn blocking_request(
|
||||
client: &super::ConsulClient,
|
||||
request: &ConsulBlockingRequest,
|
||||
requested_index: u64,
|
||||
) -> Result<ConsulBlockingResponse, String> {
|
||||
let encoded = request
|
||||
.key
|
||||
.split('/')
|
||||
.map(|segment| utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let mut url = client.api_url(&format!("/v1/kv/{encoded}"))?;
|
||||
client.append_scope(&mut url, true);
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("index", &requested_index.to_string());
|
||||
query.append_pair("wait", &format!("{}s", request.wait_seconds.clamp(1, 600)));
|
||||
if request.prefix {
|
||||
query.append_pair("recurse", "");
|
||||
}
|
||||
}
|
||||
let response = client.send(Method::GET, url, None).await?;
|
||||
let metadata = ConsulResponseMetadata::from_response(&response);
|
||||
if is_consul_kv_not_found(&response) {
|
||||
return Ok(ConsulBlockingResponse {
|
||||
entries: Vec::new(),
|
||||
metadata,
|
||||
changed: false,
|
||||
timed_out: false,
|
||||
index_reset: false,
|
||||
});
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return ensure_success(response, "watch Consul KV", client.token()).await.map(|_| unreachable!());
|
||||
}
|
||||
let body = read_bounded_response(response, "watch Consul KV").await?;
|
||||
let entries = decode_json_body::<Vec<ConsulKvEntry>>(&body, "watch Consul KV")?
|
||||
.into_iter()
|
||||
.map(record_from_entry)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ConsulBlockingResponse { entries, metadata, changed: false, timed_out: false, index_reset: false })
|
||||
}
|
||||
|
||||
async fn domain_watch_request(
|
||||
client: &super::ConsulClient,
|
||||
request: &ConsulDomainWatchRequest,
|
||||
requested_index: u64,
|
||||
) -> Result<ConsulDomainWatchResponse, String> {
|
||||
let (path, action) = match &request.target {
|
||||
ConsulDomainWatchTarget::CatalogNodes => ("/v1/catalog/nodes".to_string(), "watch Consul catalog nodes"),
|
||||
ConsulDomainWatchTarget::CatalogServices => {
|
||||
("/v1/catalog/services".to_string(), "watch Consul catalog services")
|
||||
}
|
||||
ConsulDomainWatchTarget::CatalogServiceNodes { service } => {
|
||||
(format!("/v1/catalog/service/{}", encode_path_segment(service)), "watch Consul catalog service instances")
|
||||
}
|
||||
ConsulDomainWatchTarget::CatalogNodeServices { node } => {
|
||||
(format!("/v1/catalog/node/{}", encode_path_segment(node)), "watch Consul catalog node services")
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthNode { node } => {
|
||||
(format!("/v1/health/node/{}", encode_path_segment(node)), "watch Consul node health")
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthServiceChecks { service } => {
|
||||
(format!("/v1/health/checks/{}", encode_path_segment(service)), "watch Consul service checks")
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthServiceInstances { service, passing } => {
|
||||
let suffix = passing.map(|value| format!("?passing={value}")).unwrap_or_default();
|
||||
(format!("/v1/health/service/{}{suffix}", encode_path_segment(service)), "watch Consul service instances")
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthState { state } => {
|
||||
(format!("/v1/health/state/{}", state.trim().to_ascii_lowercase()), "watch Consul health state")
|
||||
}
|
||||
};
|
||||
let mut url = client.api_url(&path)?;
|
||||
client.append_scope(&mut url, true);
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("index", &requested_index.to_string());
|
||||
query.append_pair("wait", &format!("{}s", request.wait_seconds.clamp(1, 600)));
|
||||
}
|
||||
let response = client.send(Method::GET, url, None).await?;
|
||||
let metadata = ConsulResponseMetadata::from_response(&response);
|
||||
if !response.status().is_success() {
|
||||
return ensure_success(response, action, client.token()).await.map(|_| unreachable!());
|
||||
}
|
||||
let body = read_bounded_response(response, action).await?;
|
||||
let items = match &request.target {
|
||||
ConsulDomainWatchTarget::CatalogNodes => {
|
||||
ConsulDomainWatchItems::CatalogNodes(decode_json_body::<Vec<ConsulCatalogNode>>(&body, action)?)
|
||||
}
|
||||
ConsulDomainWatchTarget::CatalogServices => {
|
||||
ConsulDomainWatchItems::CatalogServices(decode_json_body::<BTreeMap<String, Vec<String>>>(&body, action)?)
|
||||
}
|
||||
ConsulDomainWatchTarget::CatalogServiceNodes { .. } => ConsulDomainWatchItems::CatalogServiceNodes(
|
||||
decode_json_body::<Vec<ConsulCatalogServiceNode>>(&body, action)?,
|
||||
),
|
||||
ConsulDomainWatchTarget::CatalogNodeServices { .. } => {
|
||||
ConsulDomainWatchItems::CatalogNodeServices(decode_json_body::<ConsulNodeServices>(&body, action)?)
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthNode { .. } => {
|
||||
ConsulDomainWatchItems::HealthNode(decode_health_checks(&body, action)?)
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthServiceChecks { .. } => {
|
||||
ConsulDomainWatchItems::HealthServiceChecks(decode_health_checks(&body, action)?)
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthServiceInstances { .. } => {
|
||||
let mut instances = decode_json_body::<Vec<ConsulServiceInstance>>(&body, action)?;
|
||||
for instance in &mut instances {
|
||||
mark_maintenance(&mut instance.checks);
|
||||
}
|
||||
ConsulDomainWatchItems::HealthServiceInstances(instances)
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthState { .. } => {
|
||||
ConsulDomainWatchItems::HealthState(decode_health_checks(&body, action)?)
|
||||
}
|
||||
};
|
||||
Ok(ConsulDomainWatchResponse { items, metadata, changed: false, timed_out: false, index_reset: false })
|
||||
}
|
||||
|
||||
fn decode_health_checks(body: &[u8], action: &str) -> Result<Vec<ConsulHealthCheck>, String> {
|
||||
let mut checks = decode_json_body::<Vec<ConsulHealthCheck>>(body, action)?;
|
||||
mark_maintenance(&mut checks);
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
fn record_from_entry(entry: ConsulKvEntry) -> Result<ConsulKvRecord, String> {
|
||||
let bytes = entry
|
||||
.value
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
STANDARD.decode(value).map_err(|error| format!("Failed to decode Consul KV Base64 value: {error}"))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let value = match String::from_utf8(bytes.clone()) {
|
||||
Ok(data) => KvValue { encoding: KvValueEncoding::Utf8, data },
|
||||
Err(_) => KvValue { encoding: KvValueEncoding::Base64, data: STANDARD.encode(bytes) },
|
||||
};
|
||||
Ok(ConsulKvRecord {
|
||||
key: entry.key,
|
||||
value,
|
||||
flags: KvInt64(entry.flags.to_string()),
|
||||
create_index: KvInt64(entry.create_index.to_string()),
|
||||
modify_index: KvInt64(entry.modify_index.to_string()),
|
||||
lock_index: KvInt64(entry.lock_index.to_string()),
|
||||
session: entry.session.filter(|session| !session.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_request(request: &ConsulBlockingRequest) -> Result<(), String> {
|
||||
if request.operation_id.trim().is_empty() || request.operation_id.len() > 128 {
|
||||
return Err("CONSUL_OPERATION_ID_INVALID: Blocking operation ID is invalid".to_string());
|
||||
}
|
||||
if request.wait_seconds == 0 || request.wait_seconds > 600 {
|
||||
return Err("CONSUL_BLOCKING_WAIT_INVALID: Wait must be between 1 and 600 seconds".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_domain_watch_request(request: &ConsulDomainWatchRequest) -> Result<(), String> {
|
||||
if request.operation_id.trim().is_empty() || request.operation_id.len() > 128 {
|
||||
return Err("CONSUL_OPERATION_ID_INVALID: Blocking operation ID is invalid".to_string());
|
||||
}
|
||||
if request.wait_seconds == 0 || request.wait_seconds > 600 {
|
||||
return Err("CONSUL_BLOCKING_WAIT_INVALID: Wait must be between 1 and 600 seconds".to_string());
|
||||
}
|
||||
match &request.target {
|
||||
ConsulDomainWatchTarget::CatalogNodes | ConsulDomainWatchTarget::CatalogServices => Ok(()),
|
||||
ConsulDomainWatchTarget::CatalogServiceNodes { service } if !service.trim().is_empty() => Ok(()),
|
||||
ConsulDomainWatchTarget::CatalogNodeServices { node } | ConsulDomainWatchTarget::HealthNode { node }
|
||||
if !node.trim().is_empty() =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthServiceChecks { service }
|
||||
| ConsulDomainWatchTarget::HealthServiceInstances { service, .. }
|
||||
if !service.trim().is_empty() =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthState { state }
|
||||
if matches!(state.trim().to_ascii_lowercase().as_str(), "any" | "passing" | "warning" | "critical") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ConsulDomainWatchTarget::CatalogServiceNodes { .. }
|
||||
| ConsulDomainWatchTarget::HealthServiceChecks { .. }
|
||||
| ConsulDomainWatchTarget::HealthServiceInstances { .. } => {
|
||||
Err("CONSUL_INVALID_REQUEST: service is required for watch".to_string())
|
||||
}
|
||||
ConsulDomainWatchTarget::CatalogNodeServices { .. } | ConsulDomainWatchTarget::HealthNode { .. } => {
|
||||
Err("CONSUL_INVALID_REQUEST: node is required for watch".to_string())
|
||||
}
|
||||
ConsulDomainWatchTarget::HealthState { .. } => {
|
||||
Err("CONSUL_INVALID_REQUEST: health state must be any, passing, warning, or critical".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_path_segment(value: &str) -> String {
|
||||
utf8_percent_encode(value.trim(), NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn parse_index(value: &KvInt64) -> Result<u64, String> {
|
||||
value
|
||||
.as_str()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "CONSUL_BLOCKING_INDEX_INVALID: Index must be an unsigned 64-bit integer".to_string())
|
||||
}
|
||||
|
||||
fn blocking_registry() -> &'static Mutex<HashMap<String, Arc<BlockingControl>>> {
|
||||
BLOCKING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::consul::test_support::{serve_once, test_client};
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_wait_and_invalid_index() {
|
||||
let mut request = ConsulBlockingRequest {
|
||||
operation_id: "watch".to_string(),
|
||||
generation: 1,
|
||||
key: "a".to_string(),
|
||||
prefix: false,
|
||||
index: Some(KvInt64("-1".to_string())),
|
||||
wait_seconds: 0,
|
||||
};
|
||||
assert!(validate_request(&request).is_err());
|
||||
request.wait_seconds = 10;
|
||||
assert!(parse_index(request.index.as_ref().unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_catalog_and_health_watch_targets() {
|
||||
let request = ConsulDomainWatchRequest {
|
||||
operation_id: "catalog-watch".to_string(),
|
||||
generation: 4,
|
||||
target: ConsulDomainWatchTarget::CatalogServiceNodes { service: "web/api".to_string() },
|
||||
index: Some(KvInt64("0".to_string())),
|
||||
wait_seconds: 300,
|
||||
};
|
||||
assert!(validate_domain_watch_request(&request).is_ok());
|
||||
assert_eq!(parse_index(request.index.as_ref().unwrap()).unwrap(), 0);
|
||||
|
||||
let invalid = ConsulDomainWatchRequest {
|
||||
target: ConsulDomainWatchTarget::HealthState { state: "unknown".to_string() },
|
||||
..request
|
||||
};
|
||||
assert!(validate_domain_watch_request(&invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_is_bound_to_connection_scope_generation_and_operation() {
|
||||
let scope = super::super::ConsulScope {
|
||||
datacenter: "dc1".to_string(),
|
||||
partition: "partition-a".to_string(),
|
||||
namespace: "team-a".to_string(),
|
||||
};
|
||||
let key = format!("connection-a\0{}\0{}\0{}\0{}\0{}", "dc1", "partition-a", "team-a", 7, "watch-a");
|
||||
let control = Arc::new(BlockingControl::default());
|
||||
blocking_registry().lock().unwrap().insert(key.clone(), Arc::clone(&control));
|
||||
|
||||
assert!(!consul_cancel_blocking_core("connection-a", &scope, 8, "watch-a"));
|
||||
assert!(!control.cancelled.load(Ordering::Acquire));
|
||||
assert!(consul_cancel_blocking_core("connection-a", &scope, 7, "watch-a"));
|
||||
assert!(control.cancelled.load(Ordering::Acquire));
|
||||
blocking_registry().lock().unwrap().remove(&key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_kv_wire_uses_minimum_index_wait_scope_and_encoded_key() {
|
||||
let body = r#"[{"Key":"app/a b","Value":"dmFsdWU=","Flags":0,"CreateIndex":1,"ModifyIndex":9,"LockIndex":0}]"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Consul-Index: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let (url, request_rx) = serve_once(response).await;
|
||||
let client = test_client(url).await;
|
||||
let request = ConsulBlockingRequest {
|
||||
operation_id: "watch-1".to_string(),
|
||||
generation: 3,
|
||||
key: "app/a b".to_string(),
|
||||
prefix: true,
|
||||
index: None,
|
||||
wait_seconds: 10,
|
||||
};
|
||||
let result = blocking_request(&client, &request, 1).await.unwrap();
|
||||
|
||||
assert_eq!(result.metadata.index.as_ref().unwrap().as_str(), "9");
|
||||
assert_eq!(result.entries[0].value.data, "value");
|
||||
let raw = request_rx.await.unwrap();
|
||||
let headers = raw.split_once("\r\n\r\n").unwrap().0;
|
||||
assert!(headers.starts_with("GET /proxy/v1/kv/app/a%20b?"));
|
||||
for query in ["dc=dc1", "ns=team-a", "partition=partition-a", "consistent", "index=1", "wait=10s", "recurse"] {
|
||||
assert!(headers.contains(query), "missing query component {query}: {headers}");
|
||||
}
|
||||
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::KvInt64;
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ensure_writable_core};
|
||||
use super::kv::{MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES};
|
||||
use super::txn::{delete_cas_operation, ConsulTxnRequest, MAX_TXN_OPERATIONS};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDeleteCandidate {
|
||||
pub key: String,
|
||||
pub modify_index: KvInt64,
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDeletePrefixPreview {
|
||||
pub prefix: String,
|
||||
pub candidates: Vec<ConsulDeleteCandidate>,
|
||||
pub filtered_by_acls: Option<bool>,
|
||||
pub complete: bool,
|
||||
pub can_execute: bool,
|
||||
pub locked: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDeletePrefixRequest {
|
||||
pub prefix: String,
|
||||
pub expected: Vec<ConsulDeleteCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulDeleteOutcome {
|
||||
Succeeded,
|
||||
Conflicted,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDeleteResultItem {
|
||||
pub key: String,
|
||||
pub outcome: ConsulDeleteOutcome,
|
||||
pub message: Option<String>,
|
||||
pub batch: Option<usize>,
|
||||
pub op_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulDeletePrefixReport {
|
||||
pub items: Vec<ConsulDeleteResultItem>,
|
||||
pub succeeded: usize,
|
||||
pub conflicted: usize,
|
||||
pub failed: usize,
|
||||
pub atomic: bool,
|
||||
}
|
||||
|
||||
pub async fn consul_delete_prefix_preview_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
prefix: &str,
|
||||
) -> Result<ConsulDeletePrefixPreview, String> {
|
||||
validate_prefix(prefix)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let recursive = client.list_recursive(prefix, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES).await?;
|
||||
let candidates = recursive
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| ConsulDeleteCandidate { key: entry.key, modify_index: entry.modify_index, session: entry.session })
|
||||
.collect::<Vec<_>>();
|
||||
let locked = candidates.iter().filter(|candidate| candidate.session.is_some()).count();
|
||||
let can_execute =
|
||||
recursive.complete && recursive.filtered_by_acls != Some(true) && locked == 0 && !candidates.is_empty();
|
||||
Ok(ConsulDeletePrefixPreview {
|
||||
prefix: prefix.to_string(),
|
||||
candidates,
|
||||
filtered_by_acls: recursive.filtered_by_acls,
|
||||
complete: recursive.complete,
|
||||
can_execute,
|
||||
locked,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn consul_delete_prefix_execute_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulDeletePrefixRequest,
|
||||
) -> Result<ConsulDeletePrefixReport, String> {
|
||||
ensure_writable_core(state, connection_id, "delete Consul KV prefix").await?;
|
||||
validate_prefix(&request.prefix)?;
|
||||
if request.expected.is_empty() || request.expected.len() > MAX_RECURSIVE_ENTRIES {
|
||||
return Err("CONSUL_DELETE_PREVIEW_REQUIRED: A complete delete preview is required".to_string());
|
||||
}
|
||||
let preview = consul_delete_prefix_preview_core(state, connection_id, &request.prefix).await?;
|
||||
if !preview.can_execute {
|
||||
return Err(if preview.filtered_by_acls == Some(true) {
|
||||
"CONSUL_DELETE_ACL_FILTERED: Prefix deletion is blocked because ACL policies hide keys".to_string()
|
||||
} else if preview.locked > 0 {
|
||||
format!("CONSUL_DELETE_LOCKED: Prefix contains {} Session-locked keys", preview.locked)
|
||||
} else {
|
||||
"CONSUL_DELETE_PREVIEW_INVALID: Prefix cannot be deleted from the current preview".to_string()
|
||||
});
|
||||
}
|
||||
ensure_same_preview(&request.expected, &preview.candidates)?;
|
||||
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut candidates = preview.candidates;
|
||||
candidates.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
let atomic = candidates.len() <= MAX_TXN_OPERATIONS;
|
||||
let mut items = Vec::with_capacity(candidates.len());
|
||||
for (batch_index, chunk) in candidates.chunks(MAX_TXN_OPERATIONS).enumerate() {
|
||||
let operations = chunk
|
||||
.iter()
|
||||
.map(|candidate| delete_cas_operation(candidate.key.clone(), candidate.modify_index.clone()))
|
||||
.collect();
|
||||
match client.txn(ConsulTxnRequest { operations }).await {
|
||||
Ok(result) if result.committed => {
|
||||
items.extend(chunk.iter().enumerate().map(|(op_index, candidate)| ConsulDeleteResultItem {
|
||||
key: candidate.key.clone(),
|
||||
outcome: ConsulDeleteOutcome::Succeeded,
|
||||
message: None,
|
||||
batch: Some(batch_index),
|
||||
op_index: Some(op_index),
|
||||
}))
|
||||
}
|
||||
Ok(result) => {
|
||||
for (op_index, candidate) in chunk.iter().enumerate() {
|
||||
let error = result.errors.iter().find(|error| error.op_index == op_index);
|
||||
items.push(ConsulDeleteResultItem {
|
||||
key: candidate.key.clone(),
|
||||
outcome: if error.is_some() {
|
||||
ConsulDeleteOutcome::Conflicted
|
||||
} else {
|
||||
ConsulDeleteOutcome::Failed
|
||||
},
|
||||
message: Some(
|
||||
error
|
||||
.map(|error| error.message.clone())
|
||||
.unwrap_or_else(|| "Transaction batch was aborted".to_string()),
|
||||
),
|
||||
batch: Some(batch_index),
|
||||
op_index: Some(op_index),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => items.extend(chunk.iter().enumerate().map(|(op_index, candidate)| ConsulDeleteResultItem {
|
||||
key: candidate.key.clone(),
|
||||
outcome: ConsulDeleteOutcome::Failed,
|
||||
message: Some(error.clone()),
|
||||
batch: Some(batch_index),
|
||||
op_index: Some(op_index),
|
||||
})),
|
||||
}
|
||||
}
|
||||
let succeeded = items.iter().filter(|item| item.outcome == ConsulDeleteOutcome::Succeeded).count();
|
||||
let conflicted = items.iter().filter(|item| item.outcome == ConsulDeleteOutcome::Conflicted).count();
|
||||
let failed = items.iter().filter(|item| item.outcome == ConsulDeleteOutcome::Failed).count();
|
||||
Ok(ConsulDeletePrefixReport { items, succeeded, conflicted, failed, atomic })
|
||||
}
|
||||
|
||||
fn ensure_same_preview(expected: &[ConsulDeleteCandidate], current: &[ConsulDeleteCandidate]) -> Result<(), String> {
|
||||
let expected =
|
||||
expected.iter().map(|candidate| (&candidate.key, candidate.modify_index.as_str())).collect::<BTreeMap<_, _>>();
|
||||
let current =
|
||||
current.iter().map(|candidate| (&candidate.key, candidate.modify_index.as_str())).collect::<BTreeMap<_, _>>();
|
||||
if expected != current {
|
||||
return Err(
|
||||
"CONSUL_DELETE_PREVIEW_STALE: Prefix contents changed after preview; refresh before deleting".to_string()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_prefix(prefix: &str) -> Result<(), String> {
|
||||
if prefix.trim().is_empty() {
|
||||
return Err("CONSUL_DELETE_PREFIX_REQUIRED: Deleting the entire Consul KV root is not allowed".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ConsulClient};
|
||||
use super::response::decode_json_response;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConsulCapabilityStatus {
|
||||
Supported,
|
||||
Unsupported,
|
||||
Disabled,
|
||||
Forbidden,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulCapabilities {
|
||||
pub version: Option<String>,
|
||||
pub datacenter: Option<String>,
|
||||
pub node_name: Option<String>,
|
||||
pub server: Option<bool>,
|
||||
pub edition: Option<String>,
|
||||
pub agent: ConsulCapabilityStatus,
|
||||
pub catalog: ConsulCapabilityStatus,
|
||||
pub health: ConsulCapabilityStatus,
|
||||
pub sessions: ConsulCapabilityStatus,
|
||||
pub acl: ConsulCapabilityStatus,
|
||||
pub auth_methods: ConsulCapabilityStatus,
|
||||
pub binding_rules: ConsulCapabilityStatus,
|
||||
pub templated_policies: ConsulCapabilityStatus,
|
||||
pub namespaces: ConsulCapabilityStatus,
|
||||
pub partitions: ConsulCapabilityStatus,
|
||||
pub config_entries: ConsulCapabilityStatus,
|
||||
pub intentions: ConsulCapabilityStatus,
|
||||
pub peering: ConsulCapabilityStatus,
|
||||
pub exported_services: ConsulCapabilityStatus,
|
||||
pub prepared_queries: ConsulCapabilityStatus,
|
||||
pub events: ConsulCapabilityStatus,
|
||||
pub coordinates: ConsulCapabilityStatus,
|
||||
pub operator_autopilot: ConsulCapabilityStatus,
|
||||
pub operator_raft: ConsulCapabilityStatus,
|
||||
pub operator_keyring: ConsulCapabilityStatus,
|
||||
pub operator_usage: ConsulCapabilityStatus,
|
||||
pub operator_license: ConsulCapabilityStatus,
|
||||
pub audit: ConsulCapabilityStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentSelfResponse {
|
||||
#[serde(rename = "Config")]
|
||||
config: AgentSelfConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct AgentSelfConfig {
|
||||
version: Option<String>,
|
||||
datacenter: Option<String>,
|
||||
node_name: Option<String>,
|
||||
server: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn consul_capabilities_core(state: &AppState, connection_id: &str) -> Result<ConsulCapabilities, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let (
|
||||
mut capabilities,
|
||||
acl,
|
||||
auth_methods,
|
||||
binding_rules,
|
||||
templated_policies,
|
||||
namespaces,
|
||||
partitions,
|
||||
config_entries,
|
||||
intentions,
|
||||
peering,
|
||||
exported_services,
|
||||
catalog,
|
||||
health,
|
||||
sessions,
|
||||
prepared_queries,
|
||||
events,
|
||||
coordinates,
|
||||
operator_autopilot,
|
||||
operator_raft,
|
||||
operator_keyring,
|
||||
operator_usage,
|
||||
operator_license,
|
||||
audit,
|
||||
) = tokio::join!(
|
||||
probe_agent(&client),
|
||||
probe_acl_endpoint(&client),
|
||||
probe_endpoint(&client, "/v1/acl/auth-methods"),
|
||||
probe_endpoint(&client, "/v1/acl/binding-rules"),
|
||||
probe_endpoint(&client, "/v1/acl/templated-policies"),
|
||||
probe_endpoint(&client, "/v1/namespaces"),
|
||||
probe_endpoint(&client, "/v1/partitions"),
|
||||
probe_endpoint(&client, "/v1/config/service-defaults"),
|
||||
probe_endpoint(&client, "/v1/connect/intentions"),
|
||||
probe_endpoint(&client, "/v1/peerings"),
|
||||
probe_endpoint(&client, "/v1/exported-services"),
|
||||
probe_endpoint(&client, "/v1/catalog/datacenters"),
|
||||
probe_endpoint(&client, "/v1/health/state/any"),
|
||||
probe_endpoint(&client, "/v1/session/list"),
|
||||
probe_endpoint(&client, "/v1/query"),
|
||||
probe_endpoint(&client, "/v1/event/list"),
|
||||
probe_endpoint(&client, "/v1/coordinate/nodes"),
|
||||
probe_endpoint(&client, "/v1/operator/autopilot/configuration"),
|
||||
probe_endpoint(&client, "/v1/operator/raft/configuration"),
|
||||
probe_endpoint(&client, "/v1/operator/keyring"),
|
||||
probe_endpoint(&client, "/v1/operator/usage"),
|
||||
probe_endpoint(&client, "/v1/operator/license"),
|
||||
probe_audit_endpoint(&client),
|
||||
);
|
||||
capabilities.acl = acl;
|
||||
capabilities.auth_methods = auth_methods;
|
||||
capabilities.binding_rules = binding_rules;
|
||||
capabilities.templated_policies = templated_policies;
|
||||
capabilities.namespaces = namespaces;
|
||||
capabilities.partitions = partitions;
|
||||
capabilities.config_entries = config_entries;
|
||||
capabilities.intentions = intentions;
|
||||
capabilities.peering = peering;
|
||||
capabilities.exported_services = exported_services;
|
||||
capabilities.catalog = catalog;
|
||||
capabilities.health = health;
|
||||
capabilities.sessions = sessions;
|
||||
capabilities.prepared_queries = prepared_queries;
|
||||
capabilities.events = events;
|
||||
capabilities.coordinates = coordinates;
|
||||
capabilities.operator_autopilot = operator_autopilot;
|
||||
capabilities.operator_raft = operator_raft;
|
||||
capabilities.operator_keyring = operator_keyring;
|
||||
capabilities.operator_usage = operator_usage;
|
||||
capabilities.operator_license = operator_license;
|
||||
capabilities.audit = audit;
|
||||
capabilities.edition = infer_edition(capabilities.namespaces, capabilities.partitions);
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
async fn probe_agent(client: &ConsulClient) -> ConsulCapabilities {
|
||||
let Ok(url) = client.api_url("/v1/agent/self") else {
|
||||
return ConsulCapabilities::default();
|
||||
};
|
||||
let Ok(response) = client.send(Method::GET, url, None).await else {
|
||||
return ConsulCapabilities::default();
|
||||
};
|
||||
let status = capability_status(response.status());
|
||||
if status != ConsulCapabilityStatus::Supported {
|
||||
return ConsulCapabilities { agent: status, ..ConsulCapabilities::default() };
|
||||
}
|
||||
let Ok(response) = decode_json_response::<AgentSelfResponse>(response, "probe Consul agent").await else {
|
||||
return ConsulCapabilities::default();
|
||||
};
|
||||
ConsulCapabilities {
|
||||
version: response.config.version,
|
||||
datacenter: response.config.datacenter,
|
||||
node_name: response.config.node_name,
|
||||
server: response.config.server,
|
||||
agent: ConsulCapabilityStatus::Supported,
|
||||
..ConsulCapabilities::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_endpoint(client: &ConsulClient, path: &str) -> ConsulCapabilityStatus {
|
||||
let Ok(mut url) = client.api_url(path) else {
|
||||
return ConsulCapabilityStatus::Unknown;
|
||||
};
|
||||
client.append_scope(&mut url, true);
|
||||
match client.send(Method::GET, url, None).await {
|
||||
Ok(response) => capability_status(response.status()),
|
||||
Err(_) => ConsulCapabilityStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_acl_endpoint(client: &ConsulClient) -> ConsulCapabilityStatus {
|
||||
let Ok(mut url) = client.api_url("/v1/acl/token/self") else {
|
||||
return ConsulCapabilityStatus::Unknown;
|
||||
};
|
||||
client.append_scope(&mut url, true);
|
||||
match client.send(Method::GET, url, None).await {
|
||||
Ok(response) if response.status() == StatusCode::UNAUTHORIZED => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if is_acl_support_disabled(&body) {
|
||||
ConsulCapabilityStatus::Disabled
|
||||
} else {
|
||||
ConsulCapabilityStatus::Forbidden
|
||||
}
|
||||
}
|
||||
Ok(response) => capability_status(response.status()),
|
||||
Err(_) => ConsulCapabilityStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_audit_endpoint(client: &ConsulClient) -> ConsulCapabilityStatus {
|
||||
let Ok(mut url) = client.api_url("/v1/operator/audit-hash") else {
|
||||
return ConsulCapabilityStatus::Unknown;
|
||||
};
|
||||
client.append_scope(&mut url, true);
|
||||
match client.send(Method::POST, url, Some(br#"{"Input":"dbx-capability-probe"}"#.to_vec())).await {
|
||||
Ok(response) => capability_status(response.status()),
|
||||
Err(_) => ConsulCapabilityStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn capability_status(status: StatusCode) -> ConsulCapabilityStatus {
|
||||
match status {
|
||||
status if status.is_success() => ConsulCapabilityStatus::Supported,
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ConsulCapabilityStatus::Forbidden,
|
||||
StatusCode::NOT_FOUND => ConsulCapabilityStatus::Unsupported,
|
||||
_ => ConsulCapabilityStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_acl_support_disabled(body: &str) -> bool {
|
||||
body.trim().eq_ignore_ascii_case("ACL support disabled")
|
||||
}
|
||||
|
||||
fn infer_edition(namespaces: ConsulCapabilityStatus, partitions: ConsulCapabilityStatus) -> Option<String> {
|
||||
if matches!(namespaces, ConsulCapabilityStatus::Supported)
|
||||
|| matches!(partitions, ConsulCapabilityStatus::Supported)
|
||||
{
|
||||
Some("enterprise".to_string())
|
||||
} else if matches!(namespaces, ConsulCapabilityStatus::Unsupported)
|
||||
&& matches!(partitions, ConsulCapabilityStatus::Unsupported)
|
||||
{
|
||||
Some("community".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognizes_an_explicitly_disabled_acl_subsystem() {
|
||||
assert!(is_acl_support_disabled("ACL support disabled\n"));
|
||||
assert!(!is_acl_support_disabled("Permission denied"));
|
||||
assert_eq!(capability_status(StatusCode::UNAUTHORIZED), ConsulCapabilityStatus::Forbidden);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ConsulClient};
|
||||
use super::response::{decode_json_response, ensure_success, ConsulResponseMetadata};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulReadOptions {
|
||||
pub filter: Option<String>,
|
||||
pub near: Option<String>,
|
||||
pub index: Option<String>,
|
||||
pub wait: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulListResponse<T> {
|
||||
pub items: T,
|
||||
pub metadata: ConsulResponseMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulCatalogNode {
|
||||
#[serde(default, rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub datacenter: String,
|
||||
#[serde(default)]
|
||||
pub tagged_addresses: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub node_meta: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulCatalogServiceNode {
|
||||
#[serde(default, rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub datacenter: String,
|
||||
#[serde(default)]
|
||||
pub tagged_addresses: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub node_meta: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub service_kind: String,
|
||||
#[serde(default, rename = "ServiceID", alias = "ServiceId")]
|
||||
pub service_id: String,
|
||||
#[serde(default)]
|
||||
pub service_name: String,
|
||||
#[serde(default)]
|
||||
pub service_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub service_address: String,
|
||||
#[serde(default)]
|
||||
pub service_port: u16,
|
||||
#[serde(default)]
|
||||
pub service_meta: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub service_tagged_addresses: BTreeMap<String, ConsulServiceAddress>,
|
||||
#[serde(default)]
|
||||
pub service_weights: ConsulServiceWeights,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulServiceAddress {
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulServiceWeights {
|
||||
#[serde(default = "default_weight")]
|
||||
pub passing: u32,
|
||||
#[serde(default = "default_weight")]
|
||||
pub warning: u32,
|
||||
}
|
||||
|
||||
impl Default for ConsulServiceWeights {
|
||||
fn default() -> Self {
|
||||
Self { passing: 1, warning: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_weight() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulCatalogService {
|
||||
#[serde(default, rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub service: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub tagged_addresses: BTreeMap<String, ConsulServiceAddress>,
|
||||
#[serde(default)]
|
||||
pub meta: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub weights: ConsulServiceWeights,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulNodeServices {
|
||||
pub node: ConsulCatalogNode,
|
||||
#[serde(default)]
|
||||
pub services: BTreeMap<String, ConsulCatalogService>,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub async fn catalog_datacenters(&self) -> Result<Vec<String>, String> {
|
||||
let url = self.api_url("/v1/catalog/datacenters")?;
|
||||
let response =
|
||||
ensure_success(self.send(Method::GET, url, None).await?, "list Consul datacenters", self.token()).await?;
|
||||
decode_json_response(response, "list Consul datacenters").await
|
||||
}
|
||||
|
||||
pub async fn catalog_nodes(
|
||||
&self,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulCatalogNode>>, String> {
|
||||
self.read_list("/v1/catalog/nodes", options, "list Consul catalog nodes").await
|
||||
}
|
||||
|
||||
pub async fn catalog_services(
|
||||
&self,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<BTreeMap<String, Vec<String>>>, String> {
|
||||
self.read_list("/v1/catalog/services", options, "list Consul catalog services").await
|
||||
}
|
||||
|
||||
pub async fn catalog_service_nodes(
|
||||
&self,
|
||||
service: &str,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulCatalogServiceNode>>, String> {
|
||||
let service = required_segment(service, "service")?;
|
||||
self.read_list(
|
||||
&format!("/v1/catalog/service/{}", encode_segment(service)),
|
||||
options,
|
||||
"list Consul service nodes",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn catalog_node_services(
|
||||
&self,
|
||||
node: &str,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<ConsulNodeServices>, String> {
|
||||
let node = required_segment(node, "node")?;
|
||||
self.read_list(&format!("/v1/catalog/node/{}", encode_segment(node)), options, "read Consul node services")
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn read_list<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
options: &ConsulReadOptions,
|
||||
action: &str,
|
||||
) -> Result<ConsulListResponse<T>, String> {
|
||||
let mut url = self.api_url(path)?;
|
||||
self.append_scope(&mut url, true);
|
||||
append_read_options(&mut url, options);
|
||||
let response = ensure_success(self.send(Method::GET, url, None).await?, action, self.token()).await?;
|
||||
let metadata = ConsulResponseMetadata::from_response(&response);
|
||||
let items = decode_json_response(response, action).await?;
|
||||
Ok(ConsulListResponse { items, metadata })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn append_read_options(url: &mut reqwest::Url, options: &ConsulReadOptions) {
|
||||
let mut query = url.query_pairs_mut();
|
||||
for (name, value) in [
|
||||
("filter", options.filter.as_deref()),
|
||||
("near", options.near.as_deref()),
|
||||
("index", options.index.as_deref()),
|
||||
("wait", options.wait.as_deref()),
|
||||
] {
|
||||
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
query.append_pair(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn encode_segment(value: &str) -> String {
|
||||
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn required_segment<'a>(value: &'a str, name: &str) -> Result<&'a str, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(format!("CONSUL_INVALID_REQUEST: {name} is required"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub async fn consul_catalog_datacenters_core(state: &AppState, connection_id: &str) -> Result<Vec<String>, String> {
|
||||
client_for_state(state, connection_id).await?.catalog_datacenters().await
|
||||
}
|
||||
|
||||
pub async fn consul_catalog_nodes_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulCatalogNode>>, String> {
|
||||
client_for_state(state, connection_id).await?.catalog_nodes(&options).await
|
||||
}
|
||||
|
||||
pub async fn consul_catalog_services_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<BTreeMap<String, Vec<String>>>, String> {
|
||||
client_for_state(state, connection_id).await?.catalog_services(&options).await
|
||||
}
|
||||
|
||||
pub async fn consul_catalog_service_nodes_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
service: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulCatalogServiceNode>>, String> {
|
||||
client_for_state(state, connection_id).await?.catalog_service_nodes(service, &options).await
|
||||
}
|
||||
|
||||
pub async fn consul_catalog_node_services_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<ConsulNodeServices>, String> {
|
||||
client_for_state(state, connection_id).await?.catalog_node_services(node, &options).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn consul_acronym_ids_deserialize_without_becoming_empty() {
|
||||
let instance: ConsulCatalogServiceNode = serde_json::from_value(serde_json::json!({
|
||||
"ID": "node-id",
|
||||
"Node": "node-1",
|
||||
"ServiceID": "api-1",
|
||||
"ServiceName": "api"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(instance.id, "node-id");
|
||||
assert_eq!(instance.service_id, "api-1");
|
||||
|
||||
let service: ConsulCatalogService = serde_json::from_value(serde_json::json!({
|
||||
"ID": "api-1",
|
||||
"Service": "api"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(service.id, "api-1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{Method, Url};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::connection::{AppState, PoolKind};
|
||||
use crate::path_utils::expand_tilde;
|
||||
|
||||
use super::config::ConsulScope;
|
||||
use super::config::{ConsulConfig, ConsulConsistency};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConsulClient {
|
||||
config: ConsulConfig,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub async fn new(config: ConsulConfig) -> Result<Self, String> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(config.connect_timeout_secs.max(1)))
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
if config.request_timeout_secs > 0 {
|
||||
builder = builder.timeout(Duration::from_secs(config.request_timeout_secs));
|
||||
}
|
||||
if config.tls_skip_verify {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
if !config.ca_cert_path.is_empty() {
|
||||
let path = expand_tilde(&config.ca_cert_path);
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read Consul CA certificate at {path}: {error}"))?;
|
||||
let certificates = reqwest::Certificate::from_pem_bundle(&bytes)
|
||||
.or_else(|_| reqwest::Certificate::from_der(&bytes).map(|certificate| vec![certificate]))
|
||||
.map_err(|error| format!("Failed to parse Consul CA certificate at {path}: {error}"))?;
|
||||
for certificate in certificates {
|
||||
builder = builder.add_root_certificate(certificate);
|
||||
}
|
||||
}
|
||||
if !config.client_cert_path.is_empty() {
|
||||
let cert_path = expand_tilde(&config.client_cert_path);
|
||||
let key_path = expand_tilde(&config.client_key_path);
|
||||
let mut pem = tokio::fs::read(&cert_path)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read Consul client certificate at {cert_path}: {error}"))?;
|
||||
if !pem.ends_with(b"\n") {
|
||||
pem.push(b'\n');
|
||||
}
|
||||
pem.extend(
|
||||
tokio::fs::read(&key_path)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read Consul client key at {key_path}: {error}"))?,
|
||||
);
|
||||
let identity = reqwest::Identity::from_pem(&pem)
|
||||
.map_err(|error| format!("Failed to parse Consul client identity: {error}"))?;
|
||||
builder = builder.identity(identity);
|
||||
}
|
||||
if let Some((override_host, override_port)) = &config.connect_override {
|
||||
let original_host = config.base_url.host_str().ok_or("Consul server address has no host")?;
|
||||
let ip = override_host
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| format!("Consul transport target must resolve to an IP address: {override_host}"))?;
|
||||
builder = builder.resolve(original_host, SocketAddr::new(ip, *override_port));
|
||||
}
|
||||
let http = builder.build().map_err(|error| format!("Failed to initialize Consul HTTP client: {error}"))?;
|
||||
Ok(Self { config, http })
|
||||
}
|
||||
|
||||
pub async fn probe(&self) -> Result<(), String> {
|
||||
self.list_prefix("", 1, None).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn api_url(&self, path: &str) -> Result<Url, String> {
|
||||
let base = self.config.base_url.as_str().trim_end_matches('/');
|
||||
Url::parse(&format!("{base}/{}", path.trim_start_matches('/')))
|
||||
.map_err(|error| format!("Failed to build Consul API URL: {error}"))
|
||||
}
|
||||
|
||||
pub(super) fn append_scope(&self, url: &mut Url, read: bool) {
|
||||
let mut query = url.query_pairs_mut();
|
||||
if !self.config.datacenter.is_empty() {
|
||||
query.append_pair("dc", &self.config.datacenter);
|
||||
}
|
||||
if !self.config.namespace.is_empty() {
|
||||
query.append_pair("ns", &self.config.namespace);
|
||||
}
|
||||
if !self.config.partition.is_empty() {
|
||||
query.append_pair("partition", &self.config.partition);
|
||||
}
|
||||
if read {
|
||||
match self.config.consistency {
|
||||
ConsulConsistency::Default => {}
|
||||
ConsulConsistency::Stale => {
|
||||
query.append_pair("stale", "");
|
||||
}
|
||||
ConsulConsistency::Consistent => {
|
||||
query.append_pair("consistent", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let mut request = self.request(method, url);
|
||||
if let Some(body) = body {
|
||||
request = request.body(body);
|
||||
}
|
||||
request.send().await.map_err(|error| format!("Consul request failed: {}", error.without_url()))
|
||||
}
|
||||
|
||||
fn request(&self, method: Method, url: Url) -> reqwest::RequestBuilder {
|
||||
let mut request = self.http.request(method, url);
|
||||
if !self.config.token.is_empty() {
|
||||
request = request.header("X-Consul-Token", &self.config.token);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub(super) async fn send_json<T: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
mut url: Url,
|
||||
body: Option<&T>,
|
||||
read: bool,
|
||||
action: &str,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
self.append_scope(&mut url, read);
|
||||
let body = body
|
||||
.map(serde_json::to_vec)
|
||||
.transpose()
|
||||
.map_err(|error| format!("Failed to serialize Consul request: {error}"))?;
|
||||
let mut request = self.http.request(method, url);
|
||||
if !self.config.token.is_empty() {
|
||||
request = request.header("X-Consul-Token", &self.config.token);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.header(reqwest::header::CONTENT_TYPE, "application/json").body(body);
|
||||
}
|
||||
let response =
|
||||
request.send().await.map_err(|error| format!("Consul request failed: {}", error.without_url()))?;
|
||||
super::response::ensure_success(response, action, self.token()).await
|
||||
}
|
||||
|
||||
pub(super) async fn request_json<R: DeserializeOwned, T: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
body: Option<&T>,
|
||||
read: bool,
|
||||
action: &str,
|
||||
) -> Result<R, String> {
|
||||
let response = self.send_json(method, url, body, read, action).await?;
|
||||
super::response::decode_json_response(response, action).await
|
||||
}
|
||||
|
||||
pub(super) async fn request_json_unscoped<R: DeserializeOwned, T: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
body: Option<&T>,
|
||||
action: &str,
|
||||
) -> Result<R, String> {
|
||||
let body = body
|
||||
.map(serde_json::to_vec)
|
||||
.transpose()
|
||||
.map_err(|error| format!("Failed to serialize Consul request: {error}"))?;
|
||||
let mut request = self.request(method, url);
|
||||
if let Some(body) = body {
|
||||
request = request.header(reqwest::header::CONTENT_TYPE, "application/json").body(body);
|
||||
}
|
||||
let response =
|
||||
request.send().await.map_err(|error| format!("Consul request failed: {}", error.without_url()))?;
|
||||
let response = super::response::ensure_success(response, action, self.token()).await?;
|
||||
super::response::decode_json_response(response, action).await
|
||||
}
|
||||
|
||||
pub(super) fn token(&self) -> &str {
|
||||
&self.config.token
|
||||
}
|
||||
|
||||
pub(super) fn datacenter(&self) -> &str {
|
||||
&self.config.datacenter
|
||||
}
|
||||
|
||||
pub(super) fn namespace(&self) -> &str {
|
||||
&self.config.namespace
|
||||
}
|
||||
|
||||
pub(super) fn partition(&self) -> &str {
|
||||
&self.config.partition
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> ConsulScope {
|
||||
self.config.scope()
|
||||
}
|
||||
|
||||
pub(super) fn config(&self) -> &ConsulConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub(super) fn operator_feature_enabled(&self, feature: &str) -> bool {
|
||||
match feature {
|
||||
"snapshot_restore" => self.config.operator_snapshot_restore_enabled,
|
||||
"autopilot" => self.config.operator_autopilot_write_enabled,
|
||||
"raft" => self.config.operator_raft_write_enabled,
|
||||
"keyring" => self.config.operator_keyring_write_enabled,
|
||||
"license" => self.config.operator_license_write_enabled,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn client_for_state(state: &AppState, connection_id: &str) -> Result<ConsulClient, String> {
|
||||
state.get_or_create_pool(connection_id, None).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id) {
|
||||
Some(PoolKind::Consul(client)) => Ok(client.clone()),
|
||||
Some(_) => Err("Connection is not a Consul connection".to_string()),
|
||||
None => Err("Connection not found".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_writable_core(state: &AppState, connection_id: &str, action: &str) -> Result<(), String> {
|
||||
if let Some(name) = crate::query::connection_readonly_name(state, connection_id).await {
|
||||
return Err(format!(
|
||||
"CONSUL_READ_ONLY: connection '{name}' has read-only protection enabled; {action} blocked"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_proxy_path_scope_and_token_header() {
|
||||
let client = test_client("fixture-secret");
|
||||
let mut url = client.api_url("/v1/catalog/nodes").unwrap();
|
||||
client.append_scope(&mut url, true);
|
||||
let request = client.request(Method::GET, url).build().unwrap();
|
||||
assert_eq!(request.method(), Method::GET);
|
||||
assert_eq!(request.url().path(), "/proxy/v1/catalog/nodes");
|
||||
assert_eq!(request.url().query(), Some("dc=dc1&ns=ns1&partition=part1"));
|
||||
assert_eq!(request.headers().get("X-Consul-Token").unwrap(), "fixture-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_error_fields() {
|
||||
let body = format!("SecretID=fixture-secret {}", "x".repeat(8_000));
|
||||
let redacted = super::super::response::redact_sensitive(&body[..4096], "fixture-secret");
|
||||
assert!(!redacted.contains("fixture-secret"));
|
||||
assert!(redacted.contains("[REDACTED]"));
|
||||
assert!(redacted.len() < 4_100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_write_flags_are_independent_and_unknown_features_stay_disabled() {
|
||||
let mut client = test_client("");
|
||||
for feature in ["snapshot_restore", "autopilot", "raft", "keyring", "license", "unknown"] {
|
||||
assert!(!client.operator_feature_enabled(feature));
|
||||
}
|
||||
|
||||
let cases = [("snapshot_restore", 0usize), ("autopilot", 1), ("raft", 2), ("keyring", 3), ("license", 4)];
|
||||
for (enabled_feature, enabled_index) in cases {
|
||||
client.config.operator_snapshot_restore_enabled = enabled_index == 0;
|
||||
client.config.operator_autopilot_write_enabled = enabled_index == 1;
|
||||
client.config.operator_raft_write_enabled = enabled_index == 2;
|
||||
client.config.operator_keyring_write_enabled = enabled_index == 3;
|
||||
client.config.operator_license_write_enabled = enabled_index == 4;
|
||||
for (feature, index) in
|
||||
["snapshot_restore", "autopilot", "raft", "keyring", "license"].into_iter().zip(0usize..)
|
||||
{
|
||||
assert_eq!(client.operator_feature_enabled(feature), index == enabled_index, "{enabled_feature}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_client(token: &str) -> ConsulClient {
|
||||
ConsulClient { config: test_config(token), http: reqwest::Client::new() }
|
||||
}
|
||||
|
||||
fn test_config(token: &str) -> ConsulConfig {
|
||||
ConsulConfig {
|
||||
base_url: Url::parse("http://127.0.0.1:8500/proxy").unwrap(),
|
||||
token: token.to_string(),
|
||||
datacenter: "dc1".to_string(),
|
||||
namespace: "ns1".to_string(),
|
||||
partition: "part1".to_string(),
|
||||
consistency: ConsulConsistency::Default,
|
||||
tls_skip_verify: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
connect_timeout_secs: 2,
|
||||
request_timeout_secs: 2,
|
||||
connect_override: None,
|
||||
agent_target: None,
|
||||
operator_snapshot_restore_enabled: false,
|
||||
operator_autopilot_write_enabled: false,
|
||||
operator_raft_write_enabled: false,
|
||||
operator_keyring_write_enabled: false,
|
||||
operator_license_write_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use std::fmt;
|
||||
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulScope {
|
||||
pub datacenter: String,
|
||||
pub namespace: String,
|
||||
pub partition: String,
|
||||
}
|
||||
|
||||
impl ConsulScope {
|
||||
pub fn contains_wildcard(&self) -> bool {
|
||||
self.datacenter == "*" || self.namespace == "*" || self.partition == "*"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConsulConsistency {
|
||||
#[default]
|
||||
Default,
|
||||
Stale,
|
||||
Consistent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulAgentTarget {
|
||||
pub node: String,
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ConsulConfig {
|
||||
pub base_url: Url,
|
||||
pub token: String,
|
||||
pub datacenter: String,
|
||||
pub namespace: String,
|
||||
pub partition: String,
|
||||
pub consistency: ConsulConsistency,
|
||||
pub tls_skip_verify: bool,
|
||||
pub ca_cert_path: String,
|
||||
pub client_cert_path: String,
|
||||
pub client_key_path: String,
|
||||
pub connect_timeout_secs: u64,
|
||||
pub request_timeout_secs: u64,
|
||||
pub connect_override: Option<(String, u16)>,
|
||||
pub operator_snapshot_restore_enabled: bool,
|
||||
pub operator_autopilot_write_enabled: bool,
|
||||
pub operator_raft_write_enabled: bool,
|
||||
pub operator_keyring_write_enabled: bool,
|
||||
pub operator_license_write_enabled: bool,
|
||||
pub agent_target: Option<ConsulAgentTarget>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ConsulConfig {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let token = if self.token.is_empty() { "none" } else { "redacted" };
|
||||
formatter
|
||||
.debug_struct("ConsulConfig")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("token", &token)
|
||||
.field("datacenter", &self.datacenter)
|
||||
.field("namespace", &self.namespace)
|
||||
.field("partition", &self.partition)
|
||||
.field("consistency", &self.consistency)
|
||||
.field("tls_skip_verify", &self.tls_skip_verify)
|
||||
.field("ca_cert_path", &self.ca_cert_path)
|
||||
.field("client_cert_path", &self.client_cert_path)
|
||||
.field("client_key_path", &self.client_key_path)
|
||||
.field("connect_timeout_secs", &self.connect_timeout_secs)
|
||||
.field("request_timeout_secs", &self.request_timeout_secs)
|
||||
.field("connect_override", &self.connect_override)
|
||||
.field("operator_snapshot_restore_enabled", &self.operator_snapshot_restore_enabled)
|
||||
.field("operator_autopilot_write_enabled", &self.operator_autopilot_write_enabled)
|
||||
.field("operator_raft_write_enabled", &self.operator_raft_write_enabled)
|
||||
.field("operator_keyring_write_enabled", &self.operator_keyring_write_enabled)
|
||||
.field("operator_license_write_enabled", &self.operator_license_write_enabled)
|
||||
.field("agent_target", &self.agent_target)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConsulConfig {
|
||||
pub fn scope(&self) -> ConsulScope {
|
||||
ConsulScope {
|
||||
datacenter: self.datacenter.clone(),
|
||||
namespace: self.namespace.clone(),
|
||||
partition: self.partition.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_connection(connection: &ConnectionConfig) -> Result<Self, String> {
|
||||
let external = connection.external_config.as_ref();
|
||||
let server_addr =
|
||||
external_string(external, &["serverAddr", "server_addr"]).unwrap_or_else(|| connection.connection_url());
|
||||
let mut base_url =
|
||||
Url::parse(server_addr.trim()).map_err(|error| format!("Consul server address is invalid: {error}"))?;
|
||||
if !matches!(base_url.scheme(), "http" | "https") {
|
||||
return Err("Consul server address must use http or https".to_string());
|
||||
}
|
||||
if base_url.host_str().is_none() {
|
||||
return Err("Consul server address must include a host".to_string());
|
||||
}
|
||||
if !base_url.username().is_empty() || base_url.password().is_some() {
|
||||
return Err("Consul server address must not contain embedded credentials".to_string());
|
||||
}
|
||||
if base_url.query().is_some() || base_url.fragment().is_some() {
|
||||
return Err("Consul server address must not contain a query or fragment".to_string());
|
||||
}
|
||||
let normalized_path = base_url.path().trim_end_matches('/').to_string();
|
||||
base_url.set_path(&normalized_path);
|
||||
|
||||
let consistency = match external_string(external, &["consistency", "consulConsistency", "consul_consistency"])
|
||||
.unwrap_or_else(|| "default".to_string())
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"default" | "" => ConsulConsistency::Default,
|
||||
"stale" => ConsulConsistency::Stale,
|
||||
"consistent" => ConsulConsistency::Consistent,
|
||||
value => return Err(format!("Unsupported Consul consistency mode: {value}")),
|
||||
};
|
||||
|
||||
let client_cert_path = connection.client_cert_path.trim().to_string();
|
||||
let client_key_path = connection.client_key_path.trim().to_string();
|
||||
if client_cert_path.is_empty() != client_key_path.is_empty() {
|
||||
return Err("Consul client certificate and key must be configured together".to_string());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
token: connection.password.clone(),
|
||||
datacenter: external_string(external, &["datacenter", "consulDatacenter", "consul_datacenter"])
|
||||
.unwrap_or_default(),
|
||||
namespace: external_string(external, &["namespace", "consulNamespace", "consul_namespace"])
|
||||
.unwrap_or_default(),
|
||||
partition: external_string(external, &["partition", "consulPartition", "consul_partition"])
|
||||
.unwrap_or_default(),
|
||||
consistency,
|
||||
tls_skip_verify: external_bool(
|
||||
external,
|
||||
&["tlsSkipVerify", "tls_skip_verify", "consulTlsSkipVerify", "consul_tls_skip_verify"],
|
||||
)
|
||||
.unwrap_or(false),
|
||||
ca_cert_path: connection.ca_cert_path.trim().to_string(),
|
||||
client_cert_path,
|
||||
client_key_path,
|
||||
connect_timeout_secs: connection.effective_connect_timeout_secs(),
|
||||
request_timeout_secs: connection.effective_query_timeout_secs(),
|
||||
connect_override: None,
|
||||
operator_snapshot_restore_enabled: external_bool(external, &["consulOperatorSnapshotRestoreEnabled"])
|
||||
.unwrap_or(false),
|
||||
operator_autopilot_write_enabled: external_bool(external, &["consulOperatorAutopilotWriteEnabled"])
|
||||
.unwrap_or(false),
|
||||
operator_raft_write_enabled: external_bool(external, &["consulOperatorRaftWriteEnabled"]).unwrap_or(false),
|
||||
operator_keyring_write_enabled: external_bool(external, &["consulOperatorKeyringWriteEnabled"])
|
||||
.unwrap_or(false),
|
||||
operator_license_write_enabled: external_bool(external, &["consulOperatorLicenseWriteEnabled"])
|
||||
.unwrap_or(false),
|
||||
agent_target: parse_agent_target(external)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_connect_override(mut self, host: impl Into<String>, port: u16) -> Self {
|
||||
self.connect_override = Some((host.into(), port));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_agent_target(value: Option<&serde_json::Value>) -> Result<Option<ConsulAgentTarget>, String> {
|
||||
let Some(raw) = value
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|object| object.get("agentTarget").or_else(|| object.get("agent_target")))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let object = raw.as_object().ok_or("Consul agent_target must contain an explicit node and address")?;
|
||||
let node = object.get("node").and_then(serde_json::Value::as_str).unwrap_or_default().trim();
|
||||
let address = object.get("address").and_then(serde_json::Value::as_str).unwrap_or_default().trim();
|
||||
if node.is_empty() || address.is_empty() {
|
||||
return Err("Consul agent_target requires both node and address".to_string());
|
||||
}
|
||||
Ok(Some(ConsulAgentTarget { node: node.to_string(), address: address.to_string() }))
|
||||
}
|
||||
|
||||
fn external_string(value: Option<&serde_json::Value>, keys: &[&str]) -> Option<String> {
|
||||
let object = value?.as_object()?;
|
||||
keys.iter()
|
||||
.find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn external_bool(value: Option<&serde_json::Value>, keys: &[&str]) -> Option<bool> {
|
||||
let object = value?.as_object()?;
|
||||
keys.iter().find_map(|key| object.get(*key).and_then(serde_json::Value::as_bool))
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ConsulClient};
|
||||
use super::config::ConsulConsistency;
|
||||
use super::response::{decode_json_response, ensure_success, ConsulResponseMetadata, MAX_COLLECTION_ITEMS};
|
||||
|
||||
// Consul lists config entries by kind and does not expose a single inventory endpoint.
|
||||
const NAMESPACE_CONFIG_ENTRY_KINDS: &[&str] = &[
|
||||
"api-gateway",
|
||||
"bound-api-gateway",
|
||||
"file-system-certificate",
|
||||
"http-route",
|
||||
"ingress-gateway",
|
||||
"inline-certificate",
|
||||
"jwt-provider",
|
||||
"service-defaults",
|
||||
"service-intentions",
|
||||
"service-resolver",
|
||||
"service-router",
|
||||
"service-splitter",
|
||||
"tcp-route",
|
||||
"terminating-gateway",
|
||||
];
|
||||
|
||||
const PARTITION_CONFIG_ENTRY_KINDS: &[&str] = &[
|
||||
"control-plane-request-limit",
|
||||
"exported-services",
|
||||
"mesh",
|
||||
"product-usage",
|
||||
"proxy-defaults",
|
||||
"rate-limit",
|
||||
"sameness-group",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ConsulEnterpriseKind {
|
||||
Namespace,
|
||||
Partition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulNamespace {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub meta: BTreeMap<String, String>,
|
||||
#[serde(rename = "ACLs", default)]
|
||||
pub acls: ConsulNamespaceAcls,
|
||||
#[serde(default)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulNamespaceAcls {
|
||||
#[serde(default)]
|
||||
pub policy_defaults: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub role_defaults: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPartition {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub meta: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "items", rename_all = "camelCase")]
|
||||
pub enum ConsulEnterpriseList {
|
||||
Namespace(Vec<ConsulNamespace>),
|
||||
Partition(Vec<ConsulPartition>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
|
||||
pub enum ConsulEnterpriseItem {
|
||||
Namespace(ConsulNamespace),
|
||||
Partition(ConsulPartition),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "item", rename_all = "camelCase")]
|
||||
pub enum ConsulEnterpriseWrite {
|
||||
Namespace(ConsulNamespace),
|
||||
Partition(ConsulPartition),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulScopeImpact {
|
||||
pub services: usize,
|
||||
pub nodes: usize,
|
||||
pub kv_keys: usize,
|
||||
pub health_checks: usize,
|
||||
pub sessions: usize,
|
||||
pub config_entries: usize,
|
||||
pub intentions: usize,
|
||||
pub peerings: usize,
|
||||
pub namespaces: usize,
|
||||
pub acl_tokens: usize,
|
||||
pub acl_policies: usize,
|
||||
pub acl_roles: usize,
|
||||
pub acl_auth_methods: usize,
|
||||
pub acl_binding_rules: usize,
|
||||
pub complete: bool,
|
||||
pub filtered_by_acls: bool,
|
||||
pub unavailable_resources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ImpactResource {
|
||||
Services,
|
||||
Nodes,
|
||||
KvKeys,
|
||||
HealthChecks,
|
||||
Sessions,
|
||||
ConfigEntries,
|
||||
Intentions,
|
||||
Peerings,
|
||||
Namespaces,
|
||||
AclTokens,
|
||||
AclPolicies,
|
||||
AclRoles,
|
||||
AclAuthMethods,
|
||||
AclBindingRules,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ImpactInspection {
|
||||
count: usize,
|
||||
filtered_by_acls: bool,
|
||||
}
|
||||
|
||||
impl ConsulScopeImpact {
|
||||
fn record(&mut self, resource: ImpactResource, label: String, result: Result<ImpactInspection, String>) {
|
||||
match result {
|
||||
Ok(inspection) => {
|
||||
let target = match resource {
|
||||
ImpactResource::Services => &mut self.services,
|
||||
ImpactResource::Nodes => &mut self.nodes,
|
||||
ImpactResource::KvKeys => &mut self.kv_keys,
|
||||
ImpactResource::HealthChecks => &mut self.health_checks,
|
||||
ImpactResource::Sessions => &mut self.sessions,
|
||||
ImpactResource::ConfigEntries => &mut self.config_entries,
|
||||
ImpactResource::Intentions => &mut self.intentions,
|
||||
ImpactResource::Peerings => &mut self.peerings,
|
||||
ImpactResource::Namespaces => &mut self.namespaces,
|
||||
ImpactResource::AclTokens => &mut self.acl_tokens,
|
||||
ImpactResource::AclPolicies => &mut self.acl_policies,
|
||||
ImpactResource::AclRoles => &mut self.acl_roles,
|
||||
ImpactResource::AclAuthMethods => &mut self.acl_auth_methods,
|
||||
ImpactResource::AclBindingRules => &mut self.acl_binding_rules,
|
||||
};
|
||||
if let Some(total) = target.checked_add(inspection.count) {
|
||||
*target = total;
|
||||
} else {
|
||||
self.unavailable_resources.push(label);
|
||||
}
|
||||
self.filtered_by_acls |= inspection.filtered_by_acls;
|
||||
}
|
||||
Err(_) => self.unavailable_resources.push(label),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Self {
|
||||
self.unavailable_resources.sort();
|
||||
self.unavailable_resources.dedup();
|
||||
self.complete = self.unavailable_resources.is_empty() && !self.filtered_by_acls;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_enterprise_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulEnterpriseKind,
|
||||
) -> Result<ConsulEnterpriseList, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
match kind {
|
||||
ConsulEnterpriseKind::Namespace => {
|
||||
Ok(ConsulEnterpriseList::Namespace(get_json(&client, "/v1/namespaces", "list namespaces").await?))
|
||||
}
|
||||
ConsulEnterpriseKind::Partition => {
|
||||
Ok(ConsulEnterpriseList::Partition(get_json(&client, "/v1/partitions", "list partitions").await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_enterprise_get_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulEnterpriseKind,
|
||||
name: &str,
|
||||
) -> Result<ConsulEnterpriseItem, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&enterprise_path(kind, name))?;
|
||||
match kind {
|
||||
ConsulEnterpriseKind::Namespace => Ok(ConsulEnterpriseItem::Namespace(
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read namespace").await?,
|
||||
)),
|
||||
ConsulEnterpriseKind::Partition => Ok(ConsulEnterpriseItem::Partition(
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read partition").await?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_enterprise_apply_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
existing_name: Option<&str>,
|
||||
item: ConsulEnterpriseWrite,
|
||||
) -> Result<ConsulEnterpriseItem, String> {
|
||||
super::ensure_writable(state, connection_id, "Enterprise scope write").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
match item {
|
||||
ConsulEnterpriseWrite::Namespace(value) => {
|
||||
Ok(ConsulEnterpriseItem::Namespace(write_namespace(&client, existing_name, value).await?))
|
||||
}
|
||||
ConsulEnterpriseWrite::Partition(value) => {
|
||||
Ok(ConsulEnterpriseItem::Partition(write_partition(&client, existing_name, value).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_namespace(
|
||||
client: &ConsulClient,
|
||||
existing_name: Option<&str>,
|
||||
mut value: ConsulNamespace,
|
||||
) -> Result<ConsulNamespace, String> {
|
||||
value.partition = target_partition(client.partition()).to_string();
|
||||
let (method, path) = write_path(ConsulEnterpriseKind::Namespace, existing_name);
|
||||
let mut url = client.api_url(&path)?;
|
||||
append_enterprise_target(client, &mut url, ConsulEnterpriseKind::Namespace, false);
|
||||
client.request_json_unscoped(method, url, Some(&value), "write namespace").await
|
||||
}
|
||||
|
||||
async fn write_partition(
|
||||
client: &ConsulClient,
|
||||
existing_name: Option<&str>,
|
||||
value: ConsulPartition,
|
||||
) -> Result<ConsulPartition, String> {
|
||||
let (method, path) = write_path(ConsulEnterpriseKind::Partition, existing_name);
|
||||
let mut url = client.api_url(&path)?;
|
||||
append_enterprise_target(client, &mut url, ConsulEnterpriseKind::Partition, false);
|
||||
client.request_json_unscoped(method, url, Some(&value), "write partition").await
|
||||
}
|
||||
|
||||
fn target_partition(partition: &str) -> &str {
|
||||
if partition.trim().is_empty() {
|
||||
"default"
|
||||
} else {
|
||||
partition
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_enterprise_impact_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulEnterpriseKind,
|
||||
name: &str,
|
||||
) -> Result<ConsulScopeImpact, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
inspect_scope_impact(&client, kind, name).await
|
||||
}
|
||||
|
||||
pub async fn consul_enterprise_delete_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulEnterpriseKind,
|
||||
name: &str,
|
||||
) -> Result<ConsulScopeImpact, String> {
|
||||
super::ensure_writable(state, connection_id, "Enterprise scope delete").await?;
|
||||
let impact = consul_enterprise_impact_core(state, connection_id, kind, name).await?;
|
||||
if !impact.complete {
|
||||
return Err(
|
||||
"CONSUL_IMPACT_INCOMPLETE: scoped resources could not be enumerated completely; delete blocked".to_string()
|
||||
);
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url(&enterprise_path(kind, name))?;
|
||||
append_enterprise_target(&client, &mut url, kind, false);
|
||||
ensure_success(client.send(Method::DELETE, url, None).await?, "delete Enterprise scope", client.token()).await?;
|
||||
Ok(impact)
|
||||
}
|
||||
|
||||
async fn get_json<T: serde::de::DeserializeOwned>(
|
||||
client: &ConsulClient,
|
||||
path: &str,
|
||||
action: &str,
|
||||
) -> Result<T, String> {
|
||||
let url = client.api_url(path)?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, action).await
|
||||
}
|
||||
|
||||
async fn inspect_scope_impact(
|
||||
client: &ConsulClient,
|
||||
kind: ConsulEnterpriseKind,
|
||||
name: &str,
|
||||
) -> Result<ConsulScopeImpact, String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("CONSUL_INVALID_REQUEST: Enterprise scope name is required".to_string());
|
||||
}
|
||||
|
||||
let mut impact = ConsulScopeImpact::default();
|
||||
match kind {
|
||||
ConsulEnterpriseKind::Namespace => {
|
||||
inspect_namespace_resources(client, name, client.partition(), &mut impact).await;
|
||||
}
|
||||
ConsulEnterpriseKind::Partition => {
|
||||
match inspect_partition_namespaces(client, name).await {
|
||||
Ok((namespaces, filtered)) => {
|
||||
impact.record(
|
||||
ImpactResource::Namespaces,
|
||||
"namespaces".to_string(),
|
||||
Ok(ImpactInspection { count: namespaces.len(), filtered_by_acls: filtered }),
|
||||
);
|
||||
// Every partition owns a default namespace. Its absence means the list cannot prove completeness,
|
||||
// even if an older Consul version omitted the ACL-filtered response header.
|
||||
if !namespaces.contains("default") {
|
||||
impact.unavailable_resources.push("namespaces:default".to_string());
|
||||
}
|
||||
for namespace in namespaces {
|
||||
inspect_namespace_resources(client, &namespace, name, &mut impact).await;
|
||||
}
|
||||
}
|
||||
Err(_) => impact.unavailable_resources.push("namespaces".to_string()),
|
||||
}
|
||||
|
||||
record_inspection(
|
||||
client,
|
||||
&mut impact,
|
||||
ImpactResource::Nodes,
|
||||
"nodes",
|
||||
"/v1/catalog/nodes",
|
||||
name,
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
record_inspection(
|
||||
client,
|
||||
&mut impact,
|
||||
ImpactResource::Peerings,
|
||||
"peerings",
|
||||
"/v1/peerings",
|
||||
name,
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
for config_kind in PARTITION_CONFIG_ENTRY_KINDS {
|
||||
record_inspection(
|
||||
client,
|
||||
&mut impact,
|
||||
ImpactResource::ConfigEntries,
|
||||
&format!("configEntries:{config_kind}"),
|
||||
&format!("/v1/config/{config_kind}"),
|
||||
name,
|
||||
Some("default"),
|
||||
&[],
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(impact.finish())
|
||||
}
|
||||
|
||||
async fn inspect_namespace_resources(
|
||||
client: &ConsulClient,
|
||||
namespace: &str,
|
||||
partition: &str,
|
||||
impact: &mut ConsulScopeImpact,
|
||||
) {
|
||||
let resources = [
|
||||
(ImpactResource::Services, "services", "/v1/catalog/services", false),
|
||||
(ImpactResource::KvKeys, "kvKeys", "/v1/kv/", true),
|
||||
(ImpactResource::HealthChecks, "healthChecks", "/v1/health/state/any", false),
|
||||
(ImpactResource::Sessions, "sessions", "/v1/session/list", false),
|
||||
(ImpactResource::Intentions, "intentions", "/v1/connect/intentions", false),
|
||||
(ImpactResource::AclTokens, "aclTokens", "/v1/acl/tokens", false),
|
||||
(ImpactResource::AclPolicies, "aclPolicies", "/v1/acl/policies", false),
|
||||
(ImpactResource::AclRoles, "aclRoles", "/v1/acl/roles", false),
|
||||
(ImpactResource::AclAuthMethods, "aclAuthMethods", "/v1/acl/auth-methods", false),
|
||||
(ImpactResource::AclBindingRules, "aclBindingRules", "/v1/acl/binding-rules", false),
|
||||
];
|
||||
for (resource, label, path, empty_on_not_found) in resources {
|
||||
let query = if matches!(resource, ImpactResource::KvKeys) { &[("keys", "")][..] } else { &[][..] };
|
||||
record_inspection(
|
||||
client,
|
||||
impact,
|
||||
resource,
|
||||
&scoped_label(label, namespace),
|
||||
path,
|
||||
partition,
|
||||
Some(namespace),
|
||||
query,
|
||||
empty_on_not_found,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
for config_kind in NAMESPACE_CONFIG_ENTRY_KINDS {
|
||||
record_inspection(
|
||||
client,
|
||||
impact,
|
||||
ImpactResource::ConfigEntries,
|
||||
&scoped_label(&format!("configEntries:{config_kind}"), namespace),
|
||||
&format!("/v1/config/{config_kind}"),
|
||||
partition,
|
||||
Some(namespace),
|
||||
&[],
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn record_inspection(
|
||||
client: &ConsulClient,
|
||||
impact: &mut ConsulScopeImpact,
|
||||
resource: ImpactResource,
|
||||
label: &str,
|
||||
path: &str,
|
||||
partition: &str,
|
||||
namespace: Option<&str>,
|
||||
query: &[(&str, &str)],
|
||||
empty_on_not_found: bool,
|
||||
) {
|
||||
let result = impact_get(client, path, partition, namespace, query, empty_on_not_found).await;
|
||||
impact.record(resource, label.to_string(), result);
|
||||
}
|
||||
|
||||
async fn impact_get(
|
||||
client: &ConsulClient,
|
||||
path: &str,
|
||||
partition: &str,
|
||||
namespace: Option<&str>,
|
||||
extra_query: &[(&str, &str)],
|
||||
empty_on_not_found: bool,
|
||||
) -> Result<ImpactInspection, String> {
|
||||
let mut url = client.api_url(path)?;
|
||||
append_explicit_scope(client, &mut url, partition, namespace, true);
|
||||
for (key, value) in extra_query {
|
||||
url.query_pairs_mut().append_pair(key, value);
|
||||
}
|
||||
let response = client.send(Method::GET, url, None).await?;
|
||||
let filtered = ConsulResponseMetadata::from_response(&response).filtered_by_acls.unwrap_or(false);
|
||||
if is_empty_not_found(response.status(), empty_on_not_found) {
|
||||
return Ok(ImpactInspection { count: 0, filtered_by_acls: filtered });
|
||||
}
|
||||
let response = ensure_success(response, "inspect scoped resources", client.token()).await?;
|
||||
let value = decode_json_response::<serde_json::Value>(response, "inspect scoped resources").await?;
|
||||
let count = count_collection(&value)?;
|
||||
Ok(ImpactInspection { count, filtered_by_acls: filtered })
|
||||
}
|
||||
|
||||
async fn inspect_partition_namespaces(
|
||||
client: &ConsulClient,
|
||||
partition: &str,
|
||||
) -> Result<(BTreeSet<String>, bool), String> {
|
||||
let mut url = client.api_url("/v1/namespaces")?;
|
||||
append_explicit_scope(client, &mut url, partition, None, true);
|
||||
let response = ensure_success(
|
||||
client.send(Method::GET, url, None).await?,
|
||||
"list namespaces for partition impact",
|
||||
client.token(),
|
||||
)
|
||||
.await?;
|
||||
let filtered = ConsulResponseMetadata::from_response(&response).filtered_by_acls.unwrap_or(false);
|
||||
let value = decode_json_response::<serde_json::Value>(response, "list namespaces for partition impact").await?;
|
||||
let items = value
|
||||
.as_array()
|
||||
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: namespace impact response must be an array".to_string())?;
|
||||
if items.len() > MAX_COLLECTION_ITEMS {
|
||||
return Err(format!("CONSUL_RESPONSE_TOO_LARGE: impact response exceeds {MAX_COLLECTION_ITEMS} items"));
|
||||
}
|
||||
let mut namespaces = BTreeSet::new();
|
||||
for item in items {
|
||||
let name = item
|
||||
.get("Name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: namespace impact item is missing Name".to_string())?;
|
||||
if !namespaces.insert(name.to_string()) {
|
||||
return Err(format!("CONSUL_INVALID_RESPONSE: duplicate namespace {name:?} in impact response"));
|
||||
}
|
||||
}
|
||||
Ok((namespaces, filtered))
|
||||
}
|
||||
|
||||
fn count_collection(value: &serde_json::Value) -> Result<usize, String> {
|
||||
let count = match value {
|
||||
serde_json::Value::Array(items) => items.len(),
|
||||
serde_json::Value::Object(items) => items.len(),
|
||||
_ => return Err("CONSUL_INVALID_RESPONSE: impact response must be an array or object".to_string()),
|
||||
};
|
||||
if count > MAX_COLLECTION_ITEMS {
|
||||
return Err(format!("CONSUL_RESPONSE_TOO_LARGE: impact response exceeds {MAX_COLLECTION_ITEMS} items"));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn is_empty_not_found(status: reqwest::StatusCode, empty_on_not_found: bool) -> bool {
|
||||
empty_on_not_found && status == reqwest::StatusCode::NOT_FOUND
|
||||
}
|
||||
|
||||
fn append_explicit_scope(
|
||||
client: &ConsulClient,
|
||||
url: &mut reqwest::Url,
|
||||
partition: &str,
|
||||
namespace: Option<&str>,
|
||||
read: bool,
|
||||
) {
|
||||
let mut query = url.query_pairs_mut();
|
||||
if !client.datacenter().is_empty() {
|
||||
query.append_pair("dc", client.datacenter());
|
||||
}
|
||||
if !partition.is_empty() {
|
||||
query.append_pair("partition", partition);
|
||||
}
|
||||
if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) {
|
||||
query.append_pair("ns", namespace);
|
||||
}
|
||||
if read {
|
||||
match client.config().consistency {
|
||||
ConsulConsistency::Default => {}
|
||||
ConsulConsistency::Stale => {
|
||||
query.append_pair("stale", "");
|
||||
}
|
||||
ConsulConsistency::Consistent => {
|
||||
query.append_pair("consistent", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_enterprise_target(client: &ConsulClient, url: &mut reqwest::Url, kind: ConsulEnterpriseKind, read: bool) {
|
||||
let partition = match kind {
|
||||
ConsulEnterpriseKind::Namespace => client.partition(),
|
||||
ConsulEnterpriseKind::Partition => "",
|
||||
};
|
||||
append_explicit_scope(client, url, partition, None, read);
|
||||
}
|
||||
|
||||
fn scoped_label(label: &str, namespace: &str) -> String {
|
||||
format!("{label}@namespace:{namespace}")
|
||||
}
|
||||
|
||||
fn enterprise_path(kind: ConsulEnterpriseKind, name: &str) -> String {
|
||||
let name = percent_encoding::utf8_percent_encode(name, percent_encoding::NON_ALPHANUMERIC);
|
||||
match kind {
|
||||
ConsulEnterpriseKind::Namespace => format!("/v1/namespace/{name}"),
|
||||
ConsulEnterpriseKind::Partition => format!("/v1/partition/{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_path(kind: ConsulEnterpriseKind, existing_name: Option<&str>) -> (Method, String) {
|
||||
match existing_name {
|
||||
Some(name) => (Method::PUT, enterprise_path(kind, name)),
|
||||
None => (
|
||||
Method::PUT,
|
||||
match kind {
|
||||
ConsulEnterpriseKind::Namespace => "/v1/namespace",
|
||||
ConsulEnterpriseKind::Partition => "/v1/partition",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::{serve_once, test_client as fixture_client};
|
||||
use super::*;
|
||||
use reqwest::Url;
|
||||
|
||||
#[test]
|
||||
fn enterprise_paths_encode_names() {
|
||||
assert_eq!(enterprise_path(ConsulEnterpriseKind::Namespace, "team/a"), "/v1/namespace/team%2Fa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_acl_defaults_round_trip_with_the_official_nested_shape() {
|
||||
let value = serde_json::json!({
|
||||
"Name": "team-a",
|
||||
"Partition": "payments",
|
||||
"ACLs": {
|
||||
"PolicyDefaults": [{ "Name": "node-read" }],
|
||||
"RoleDefaults": [{ "ID": "role-1" }]
|
||||
}
|
||||
});
|
||||
let namespace: ConsulNamespace = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(namespace.acls.policy_defaults.len(), 1);
|
||||
assert_eq!(namespace.acls.role_defaults.len(), 1);
|
||||
assert_eq!(namespace.partition, "payments");
|
||||
let serialized = serde_json::to_value(namespace).unwrap();
|
||||
assert_eq!(serialized["ACLs"], value["ACLs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_write_partition_is_explicit_and_defaults_safely() {
|
||||
assert_eq!(target_partition("partition-a"), "partition-a");
|
||||
assert_eq!(target_partition(""), "default");
|
||||
assert_eq!(target_partition(" "), "default");
|
||||
assert_eq!(
|
||||
write_path(ConsulEnterpriseKind::Namespace, Some("team/a")),
|
||||
(Method::PUT, "/v1/namespace/team%2Fa".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_create_uses_partition_body_and_excludes_current_namespace() {
|
||||
let response_body = r#"{"Name":"team-b","Partition":"partition-a","Description":"","Meta":{},"ACLs":{}}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
|
||||
response_body.len()
|
||||
);
|
||||
let (url, request_rx) = serve_once(response).await;
|
||||
let client = fixture_client(url).await;
|
||||
let written =
|
||||
write_namespace(&client, None, ConsulNamespace { name: "team-b".to_string(), ..Default::default() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(written.partition, "partition-a");
|
||||
|
||||
let request = request_rx.await.unwrap();
|
||||
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
assert!(headers.starts_with("PUT /proxy/v1/namespace?dc=dc1&partition=partition-a HTTP/1.1"));
|
||||
assert!(!headers.contains("ns="));
|
||||
assert!(headers.to_ascii_lowercase().contains("content-type: application/json"));
|
||||
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(body["Name"], "team-b");
|
||||
assert_eq!(body["Partition"], "partition-a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_impact_scope_uses_the_target_without_wildcards() {
|
||||
let client = test_client().await;
|
||||
let mut url = client.api_url("/v1/catalog/services").unwrap();
|
||||
append_explicit_scope(&client, &mut url, "target-partition", Some("target-namespace"), true);
|
||||
let pairs: BTreeMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
assert_eq!(pairs.get("dc").map(String::as_str), Some("dc1"));
|
||||
assert_eq!(pairs.get("partition").map(String::as_str), Some("target-partition"));
|
||||
assert_eq!(pairs.get("ns").map(String::as_str), Some("target-namespace"));
|
||||
assert!(pairs.contains_key("consistent"));
|
||||
assert!(!url.as_str().contains('*'));
|
||||
assert!(!url.as_str().contains("configured-namespace"));
|
||||
assert!(!url.as_str().contains("configured-partition"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enterprise_delete_target_does_not_append_the_connection_namespace() {
|
||||
let client = test_client().await;
|
||||
let mut namespace_url = client.api_url("/v1/namespace/team-a").unwrap();
|
||||
append_enterprise_target(&client, &mut namespace_url, ConsulEnterpriseKind::Namespace, false);
|
||||
assert_eq!(namespace_url.query(), Some("dc=dc1&partition=configured-partition"));
|
||||
|
||||
let mut partition_url = client.api_url("/v1/partition/team-a").unwrap();
|
||||
append_enterprise_target(&client, &mut partition_url, ConsulEnterpriseKind::Partition, false);
|
||||
assert_eq!(partition_url.query(), Some("dc=dc1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_is_incomplete_when_any_result_is_filtered_or_unavailable() {
|
||||
let mut impact = ConsulScopeImpact::default();
|
||||
impact.record(
|
||||
ImpactResource::Services,
|
||||
"services".to_string(),
|
||||
Ok(ImpactInspection { count: 2, filtered_by_acls: true }),
|
||||
);
|
||||
impact.record(ImpactResource::Sessions, "sessions".to_string(), Err("forbidden".to_string()));
|
||||
let impact = impact.finish();
|
||||
assert_eq!(impact.services, 2);
|
||||
assert!(impact.filtered_by_acls);
|
||||
assert_eq!(impact.unavailable_resources, vec!["sessions"]);
|
||||
assert!(!impact.complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_is_complete_only_after_all_recorded_results_are_unfiltered() {
|
||||
let mut impact = ConsulScopeImpact::default();
|
||||
impact.record(
|
||||
ImpactResource::KvKeys,
|
||||
"kvKeys".to_string(),
|
||||
Ok(ImpactInspection { count: 3, filtered_by_acls: false }),
|
||||
);
|
||||
impact.record(
|
||||
ImpactResource::ConfigEntries,
|
||||
"configEntries".to_string(),
|
||||
Ok(ImpactInspection { count: 4, filtered_by_acls: false }),
|
||||
);
|
||||
let impact = impact.finish();
|
||||
assert_eq!(impact.kv_keys, 3);
|
||||
assert_eq!(impact.config_entries, 4);
|
||||
assert!(impact.complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_collection_count_rejects_non_collections_and_excess_items() {
|
||||
assert!(count_collection(&serde_json::json!(false)).is_err());
|
||||
let oversized = serde_json::Value::Array(vec![serde_json::Value::Null; MAX_COLLECTION_ITEMS + 1]);
|
||||
assert!(count_collection(&oversized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_entry_inventory_covers_namespace_and_partition_scopes_without_duplicates() {
|
||||
let all = NAMESPACE_CONFIG_ENTRY_KINDS
|
||||
.iter()
|
||||
.chain(PARTITION_CONFIG_ENTRY_KINDS.iter())
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(all.len(), NAMESPACE_CONFIG_ENTRY_KINDS.len() + PARTITION_CONFIG_ENTRY_KINDS.len());
|
||||
for required in [
|
||||
"service-defaults",
|
||||
"service-intentions",
|
||||
"api-gateway",
|
||||
"bound-api-gateway",
|
||||
"jwt-provider",
|
||||
"proxy-defaults",
|
||||
"mesh",
|
||||
"exported-services",
|
||||
"sameness-group",
|
||||
] {
|
||||
assert!(all.contains(required), "missing config entry kind {required}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_explicitly_empty_collections_treat_not_found_as_zero() {
|
||||
assert!(is_empty_not_found(reqwest::StatusCode::NOT_FOUND, true));
|
||||
assert!(!is_empty_not_found(reqwest::StatusCode::NOT_FOUND, false));
|
||||
assert!(!is_empty_not_found(reqwest::StatusCode::FORBIDDEN, true));
|
||||
}
|
||||
|
||||
async fn test_client() -> ConsulClient {
|
||||
test_client_at(Url::parse("https://consul.example/proxy").unwrap()).await
|
||||
}
|
||||
|
||||
async fn test_client_at(base_url: Url) -> ConsulClient {
|
||||
ConsulClient::new(super::super::config::ConsulConfig {
|
||||
base_url,
|
||||
token: String::new(),
|
||||
datacenter: "dc1".to_string(),
|
||||
namespace: "configured-namespace".to_string(),
|
||||
partition: "configured-partition".to_string(),
|
||||
consistency: ConsulConsistency::Consistent,
|
||||
tls_skip_verify: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
request_timeout_secs: 30,
|
||||
connect_override: None,
|
||||
operator_snapshot_restore_enabled: false,
|
||||
operator_autopilot_write_enabled: false,
|
||||
operator_raft_write_enabled: false,
|
||||
operator_keyring_write_enabled: false,
|
||||
operator_license_write_enabled: false,
|
||||
agent_target: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::catalog::{
|
||||
encode_segment, ConsulCatalogNode, ConsulListResponse, ConsulReadOptions, ConsulServiceAddress,
|
||||
ConsulServiceWeights,
|
||||
};
|
||||
use super::client::{client_for_state, ConsulClient};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulHealthCheck {
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default, rename = "CheckID", alias = "CheckId")]
|
||||
pub check_id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
pub output: String,
|
||||
#[serde(default, rename = "ServiceID", alias = "ServiceId")]
|
||||
pub service_id: String,
|
||||
#[serde(default)]
|
||||
pub service_name: String,
|
||||
#[serde(default)]
|
||||
pub service_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "Type")]
|
||||
pub type_: String,
|
||||
#[serde(default)]
|
||||
pub exposed_port: u16,
|
||||
#[serde(default)]
|
||||
pub definition: ConsulHealthCheckDefinition,
|
||||
#[serde(default, rename = "CreateIndex")]
|
||||
pub create_index: u64,
|
||||
#[serde(default, rename = "ModifyIndex")]
|
||||
pub modify_index: u64,
|
||||
#[serde(default, skip_deserializing)]
|
||||
pub maintenance: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulHealthCheckDefinition {
|
||||
#[serde(default)]
|
||||
pub http: String,
|
||||
#[serde(default)]
|
||||
pub tcp: String,
|
||||
#[serde(default)]
|
||||
pub grpc: String,
|
||||
#[serde(default)]
|
||||
pub interval: String,
|
||||
#[serde(default)]
|
||||
pub timeout: String,
|
||||
#[serde(default)]
|
||||
pub ttl: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulHealthService {
|
||||
#[serde(default, rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub service: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub tagged_addresses: BTreeMap<String, ConsulServiceAddress>,
|
||||
#[serde(default)]
|
||||
pub meta: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub weights: ConsulServiceWeights,
|
||||
#[serde(default)]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulServiceInstance {
|
||||
pub node: ConsulCatalogNode,
|
||||
pub service: ConsulHealthService,
|
||||
#[serde(default)]
|
||||
pub checks: Vec<ConsulHealthCheck>,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub async fn health_node_checks(
|
||||
&self,
|
||||
node: &str,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
|
||||
let mut response: ConsulListResponse<Vec<ConsulHealthCheck>> = self
|
||||
.read_list(
|
||||
&format!("/v1/health/node/{}", encode_segment(required(node, "node")?)),
|
||||
options,
|
||||
"read Consul node health",
|
||||
)
|
||||
.await?;
|
||||
mark_maintenance(&mut response.items);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn health_service_checks(
|
||||
&self,
|
||||
service: &str,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
|
||||
let mut response: ConsulListResponse<Vec<ConsulHealthCheck>> = self
|
||||
.read_list(
|
||||
&format!("/v1/health/checks/{}", encode_segment(required(service, "service")?)),
|
||||
options,
|
||||
"read Consul service checks",
|
||||
)
|
||||
.await?;
|
||||
mark_maintenance(&mut response.items);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn health_service_instances(
|
||||
&self,
|
||||
service: &str,
|
||||
passing: Option<bool>,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulServiceInstance>>, String> {
|
||||
let mut path = format!("/v1/health/service/{}", encode_segment(required(service, "service")?));
|
||||
if let Some(passing) = passing {
|
||||
path.push_str(if passing { "?passing=true" } else { "?passing=false" });
|
||||
}
|
||||
let mut response: ConsulListResponse<Vec<ConsulServiceInstance>> =
|
||||
self.read_list(&path, options, "read Consul service instances").await?;
|
||||
for instance in &mut response.items {
|
||||
mark_maintenance(&mut instance.checks);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn health_state_checks(
|
||||
&self,
|
||||
state: &str,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
|
||||
let state = state.trim().to_ascii_lowercase();
|
||||
if !matches!(state.as_str(), "any" | "passing" | "warning" | "critical") {
|
||||
return Err("CONSUL_INVALID_REQUEST: health state must be any, passing, warning, or critical".to_string());
|
||||
}
|
||||
let mut response: ConsulListResponse<Vec<ConsulHealthCheck>> =
|
||||
self.read_list(&format!("/v1/health/state/{state}"), options, "read Consul health state").await?;
|
||||
mark_maintenance(&mut response.items);
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn required<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Err(format!("CONSUL_INVALID_REQUEST: {field} is required"))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mark_maintenance(checks: &mut [ConsulHealthCheck]) {
|
||||
for check in checks {
|
||||
check.maintenance =
|
||||
check.check_id == "_node_maintenance" || check.check_id.starts_with("_service_maintenance:");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_health_node_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
|
||||
client_for_state(state, connection_id).await?.health_node_checks(node, &options).await
|
||||
}
|
||||
|
||||
pub async fn consul_health_checks_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
service: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
|
||||
client_for_state(state, connection_id).await?.health_service_checks(service, &options).await
|
||||
}
|
||||
|
||||
pub async fn consul_health_service_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
service: &str,
|
||||
passing: Option<bool>,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulServiceInstance>>, String> {
|
||||
client_for_state(state, connection_id).await?.health_service_instances(service, passing, &options).await
|
||||
}
|
||||
|
||||
pub async fn consul_health_state_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
health_state: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulHealthCheck>>, String> {
|
||||
client_for_state(state, connection_id).await?.health_state_checks(health_state, &options).await
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use base64::Engine;
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use reqwest::{Method, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::{
|
||||
KvDeleteOptions, KvDeleteResponse, KvGetResponse, KvInt64, KvKeyMetadata, KvKeySummary, KvListPrefixResponse,
|
||||
KvPutOptions, KvPutResponse, KvValue, KvValueEncoding, KvWriteMode,
|
||||
};
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
|
||||
use super::response::{
|
||||
decode_json_body, decode_json_response, decode_text_response, ensure_success, is_consul_kv_not_found,
|
||||
ConsulResponseMetadata, MAX_RESPONSE_BYTES,
|
||||
};
|
||||
use super::types::ConsulKvEntry;
|
||||
|
||||
const MAX_VALUE_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_RECURSIVE_ENTRIES: usize = 10_000;
|
||||
pub const MAX_RECURSIVE_VALUE_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_RECURSIVE_RESPONSE_BYTES: usize = MAX_RESPONSE_BYTES;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulKvRecord {
|
||||
pub key: String,
|
||||
pub value: KvValue,
|
||||
pub flags: KvInt64,
|
||||
pub create_index: KvInt64,
|
||||
pub modify_index: KvInt64,
|
||||
pub lock_index: KvInt64,
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulRecursiveListResponse {
|
||||
pub entries: Vec<ConsulKvRecord>,
|
||||
pub index: Option<KvInt64>,
|
||||
pub filtered_by_acls: Option<bool>,
|
||||
pub total_value_bytes: u64,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulLockRequest {
|
||||
pub key: String,
|
||||
pub session: String,
|
||||
pub value: KvValue,
|
||||
pub flags: Option<KvInt64>,
|
||||
pub expected_modify_index: Option<KvInt64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulLockResponse {
|
||||
pub acquired: bool,
|
||||
pub key: String,
|
||||
pub session: String,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub(super) async fn acquire_lock(&self, request: ConsulLockRequest) -> Result<ConsulLockResponse, String> {
|
||||
let session = required_lock_field(&request.session, "session")?;
|
||||
let bytes = value_bytes(&request.value)?;
|
||||
if bytes.len() > MAX_VALUE_BYTES {
|
||||
return Err(format!("CONSUL_VALUE_TOO_LARGE: Consul KV values cannot exceed {MAX_VALUE_BYTES} bytes"));
|
||||
}
|
||||
let current = match self.get_required_entry(&request.key).await {
|
||||
Ok(current) => Some(current),
|
||||
Err(error) if error.starts_with("CONSUL_KEY_NOT_FOUND:") => None,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if let Some(current) = ¤t {
|
||||
if let Some(owner) = current.session.as_deref().filter(|owner| !owner.is_empty()) {
|
||||
if owner != session {
|
||||
return Err("CONSUL_LOCK_CONFLICT: The key is held by a different Consul Session".to_string());
|
||||
}
|
||||
}
|
||||
let expected = request
|
||||
.expected_modify_index
|
||||
.as_ref()
|
||||
.ok_or("CONSUL_CAS_REQUIRED: Acquiring an existing Consul lock requires its ModifyIndex")?;
|
||||
if parse_u64(expected)? != current.modify_index {
|
||||
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before lock acquisition".to_string());
|
||||
}
|
||||
} else if request.expected_modify_index.as_ref().is_some_and(|value| value.as_str() != "0") {
|
||||
return Err("CONSUL_CAS_CONFLICT: The Consul KV key no longer exists".to_string());
|
||||
}
|
||||
|
||||
let cas = current.as_ref().map(|entry| entry.modify_index).unwrap_or(0);
|
||||
let flags = request
|
||||
.flags
|
||||
.as_ref()
|
||||
.map(parse_u64)
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| current.as_ref().map(|entry| entry.flags).unwrap_or(0));
|
||||
let mut url = self.kv_url(&request.key)?;
|
||||
self.append_scope(&mut url, false);
|
||||
url.query_pairs_mut()
|
||||
.append_pair("acquire", session)
|
||||
.append_pair("cas", &cas.to_string())
|
||||
.append_pair("flags", &flags.to_string());
|
||||
let response =
|
||||
ensure_success(self.send(Method::PUT, url, Some(bytes)).await?, "acquire Consul KV lock", self.token())
|
||||
.await?;
|
||||
let acquired = decode_text_response(response, "acquire Consul KV lock").await?;
|
||||
if acquired.trim() != "true" {
|
||||
return Err("CONSUL_LOCK_CONFLICT: Consul refused lock acquisition".to_string());
|
||||
}
|
||||
Ok(ConsulLockResponse { acquired: true, key: request.key, session: session.to_string() })
|
||||
}
|
||||
|
||||
pub(super) async fn release_lock(&self, key: &str, session: &str) -> Result<ConsulLockResponse, String> {
|
||||
let session = required_lock_field(session, "session")?;
|
||||
let current = self.get_required_entry(key).await?;
|
||||
if current.session.as_deref() != Some(session) {
|
||||
return Err("CONSUL_LOCK_SESSION_MISMATCH: Only the Session holding this key can release it".to_string());
|
||||
}
|
||||
let bytes = decode_consul_value(current.value.as_deref())?;
|
||||
let mut url = self.kv_url(key)?;
|
||||
self.append_scope(&mut url, false);
|
||||
url.query_pairs_mut()
|
||||
.append_pair("release", session)
|
||||
.append_pair("cas", ¤t.modify_index.to_string())
|
||||
.append_pair("flags", ¤t.flags.to_string());
|
||||
let response =
|
||||
ensure_success(self.send(Method::PUT, url, Some(bytes)).await?, "release Consul KV lock", self.token())
|
||||
.await?;
|
||||
let released = decode_text_response(response, "release Consul KV lock").await?;
|
||||
if released.trim() != "true" {
|
||||
return Err("CONSUL_LOCK_CONFLICT: Consul refused lock release".to_string());
|
||||
}
|
||||
Ok(ConsulLockResponse { acquired: false, key: key.to_string(), session: session.to_string() })
|
||||
}
|
||||
|
||||
pub async fn list_recursive(
|
||||
&self,
|
||||
prefix: &str,
|
||||
max_entries: usize,
|
||||
max_value_bytes: usize,
|
||||
) -> Result<ConsulRecursiveListResponse, String> {
|
||||
self.list_recursive_with_control(prefix, max_entries, max_value_bytes, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn list_recursive_with_control(
|
||||
&self,
|
||||
prefix: &str,
|
||||
max_entries: usize,
|
||||
max_value_bytes: usize,
|
||||
control: Option<&(dyn Fn(usize) -> Result<(), String> + Send + Sync)>,
|
||||
) -> Result<ConsulRecursiveListResponse, String> {
|
||||
let max_entries = max_entries.clamp(1, MAX_RECURSIVE_ENTRIES);
|
||||
let max_value_bytes = max_value_bytes.clamp(MAX_VALUE_BYTES, MAX_RECURSIVE_VALUE_BYTES);
|
||||
let mut url = self.kv_url(prefix)?;
|
||||
self.append_scope(&mut url, true);
|
||||
url.query_pairs_mut().append_pair("recurse", "");
|
||||
let mut response = self.send(Method::GET, url, None).await?;
|
||||
let metadata = ConsulResponseMetadata::from_response(&response);
|
||||
if is_consul_kv_not_found(&response) {
|
||||
return Ok(ConsulRecursiveListResponse {
|
||||
entries: Vec::new(),
|
||||
index: metadata.index,
|
||||
filtered_by_acls: metadata.filtered_by_acls,
|
||||
total_value_bytes: 0,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return ensure_success(response, "list Consul KV prefix recursively", self.token())
|
||||
.await
|
||||
.map(|_| unreachable!());
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read Consul recursive KV response: {}", error.without_url()))?
|
||||
{
|
||||
control.map(|check| check(0)).transpose()?;
|
||||
if body.len().saturating_add(chunk.len()) > MAX_RECURSIVE_RESPONSE_BYTES {
|
||||
return Err(format!(
|
||||
"CONSUL_SCAN_LIMIT_EXCEEDED: Consul recursive KV response exceeds {} MiB",
|
||||
MAX_RECURSIVE_RESPONSE_BYTES / 1024 / 1024
|
||||
));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
let raw_entries = decode_json_body::<Vec<ConsulKvEntry>>(&body, "list Consul KV prefix recursively")?;
|
||||
if raw_entries.len() > max_entries {
|
||||
return Err(format!("CONSUL_SCAN_LIMIT_EXCEEDED: Consul prefix contains more than {max_entries} keys"));
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(raw_entries.len());
|
||||
let mut total_value_bytes = 0usize;
|
||||
for (index, entry) in raw_entries.into_iter().enumerate() {
|
||||
control.map(|check| check(index + 1)).transpose()?;
|
||||
let bytes = decode_consul_value(entry.value.as_deref())?;
|
||||
total_value_bytes = total_value_bytes.saturating_add(bytes.len());
|
||||
if total_value_bytes > max_value_bytes {
|
||||
return Err(format!(
|
||||
"CONSUL_SCAN_LIMIT_EXCEEDED: Consul prefix values exceed {} MiB",
|
||||
max_value_bytes / 1024 / 1024
|
||||
));
|
||||
}
|
||||
entries.push(ConsulKvRecord {
|
||||
key: entry.key,
|
||||
value: value_from_bytes(&bytes),
|
||||
flags: KvInt64(entry.flags.to_string()),
|
||||
create_index: KvInt64(entry.create_index.to_string()),
|
||||
modify_index: KvInt64(entry.modify_index.to_string()),
|
||||
lock_index: KvInt64(entry.lock_index.to_string()),
|
||||
session: entry.session.filter(|session| !session.is_empty()),
|
||||
});
|
||||
}
|
||||
Ok(ConsulRecursiveListResponse {
|
||||
entries,
|
||||
index: metadata.index,
|
||||
filtered_by_acls: metadata.filtered_by_acls,
|
||||
total_value_bytes: total_value_bytes as u64,
|
||||
complete: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_prefix(
|
||||
&self,
|
||||
prefix: &str,
|
||||
limit: usize,
|
||||
continuation: Option<&str>,
|
||||
) -> Result<KvListPrefixResponse, String> {
|
||||
let mut url = self.kv_url(prefix)?;
|
||||
self.append_scope(&mut url, true);
|
||||
url.query_pairs_mut().append_pair("keys", "").append_pair("separator", "/");
|
||||
let response = self.send(Method::GET, url, None).await?;
|
||||
let metadata = ConsulResponseMetadata::from_response(&response);
|
||||
if is_consul_kv_not_found(&response) {
|
||||
return Ok(KvListPrefixResponse {
|
||||
keys: Vec::new(),
|
||||
continuation: None,
|
||||
revision: metadata.index,
|
||||
filtered_by_acls: metadata.filtered_by_acls,
|
||||
});
|
||||
}
|
||||
let response = ensure_success(response, "list KV keys", self.token()).await?;
|
||||
let mut keys = decode_json_response::<Vec<String>>(response, "list KV keys").await?;
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
if !prefix.is_empty() {
|
||||
keys.retain(|key| key != prefix);
|
||||
}
|
||||
let after = continuation.map(decode_continuation).transpose()?;
|
||||
if let Some(after) = after.as_deref() {
|
||||
keys.retain(|key| key.as_str() > after);
|
||||
}
|
||||
let limit = limit.clamp(1, 500);
|
||||
let has_more = keys.len() > limit;
|
||||
keys.truncate(limit);
|
||||
let next = has_more.then(|| keys.last().map(|key| encode_continuation(key))).flatten();
|
||||
Ok(KvListPrefixResponse {
|
||||
keys: keys.into_iter().map(summary_for_key).collect(),
|
||||
continuation: next,
|
||||
revision: metadata.index,
|
||||
filtered_by_acls: metadata.filtered_by_acls,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_key(&self, key: &str) -> Result<KvGetResponse, String> {
|
||||
let mut url = self.kv_url(key)?;
|
||||
self.append_scope(&mut url, true);
|
||||
let response = self.send(Method::GET, url, None).await?;
|
||||
if is_consul_kv_not_found(&response) {
|
||||
return Ok(KvGetResponse { found: false, key: None, key_bytes: None, value: None, metadata: None });
|
||||
}
|
||||
let response = ensure_success(response, "read KV key", self.token()).await?;
|
||||
let mut entries = decode_json_response::<Vec<ConsulKvEntry>>(response, "read KV key").await?;
|
||||
let entry = entries.pop().ok_or("CONSUL_KEY_NOT_FOUND: Consul returned an empty KV response")?;
|
||||
let bytes = decode_consul_value(entry.value.as_deref())?;
|
||||
let value = value_from_bytes(&bytes);
|
||||
Ok(KvGetResponse {
|
||||
found: true,
|
||||
key: Some(entry.key.clone()),
|
||||
key_bytes: None,
|
||||
value: Some(value),
|
||||
metadata: Some(metadata_for_entry(&entry, bytes.len() as u64)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn put_key(
|
||||
&self,
|
||||
key: &str,
|
||||
value: KvValue,
|
||||
options: KvPutOptions,
|
||||
) -> Result<KvPutResponse, String> {
|
||||
let bytes = value_bytes(&value)?;
|
||||
if bytes.len() > MAX_VALUE_BYTES {
|
||||
return Err(format!(
|
||||
"CONSUL_VALUE_TOO_LARGE: Consul KV values cannot exceed {MAX_VALUE_BYTES} bytes (received {})",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
|
||||
let creating = matches!(options.write_mode, Some(KvWriteMode::Create))
|
||||
|| options.expected_create_revision.as_ref().is_some_and(|value| value.as_str() == "0");
|
||||
let (cas, flags) = if creating {
|
||||
(0, options.flags.as_ref().map(parse_u64).transpose()?.unwrap_or(0))
|
||||
} else {
|
||||
let expected = parse_u64(options.expected_mod_revision.as_ref().ok_or_else(|| {
|
||||
"CONSUL_CAS_REQUIRED: Updating a Consul KV key requires its ModifyIndex".to_string()
|
||||
})?)?;
|
||||
let current = self.get_required_entry(key).await?;
|
||||
ensure_unlocked(¤t)?;
|
||||
if current.modify_index != expected {
|
||||
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be saved".to_string());
|
||||
}
|
||||
(expected, options.flags.as_ref().map(parse_u64).transpose()?.unwrap_or(current.flags))
|
||||
};
|
||||
|
||||
let mut url = self.kv_url(key)?;
|
||||
self.append_scope(&mut url, false);
|
||||
url.query_pairs_mut().append_pair("cas", &cas.to_string()).append_pair("flags", &flags.to_string());
|
||||
let response =
|
||||
ensure_success(self.send(Method::PUT, url, Some(bytes)).await?, "write KV key", self.token()).await?;
|
||||
let written = decode_text_response(response, "write KV key").await?;
|
||||
if written.trim() != "true" {
|
||||
return Err(if creating {
|
||||
"CONSUL_KEY_ALREADY_EXISTS: The Consul KV key already exists".to_string()
|
||||
} else {
|
||||
"CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be saved".to_string()
|
||||
});
|
||||
}
|
||||
Ok(KvPutResponse { revision: None, key: Some(key.to_string()), created_key: creating.then(|| key.to_string()) })
|
||||
}
|
||||
|
||||
pub(super) async fn delete_key(&self, key: &str, options: KvDeleteOptions) -> Result<KvDeleteResponse, String> {
|
||||
let expected =
|
||||
parse_u64(options.expected_mod_revision.as_ref().ok_or_else(|| {
|
||||
"CONSUL_CAS_REQUIRED: Deleting a Consul KV key requires its ModifyIndex".to_string()
|
||||
})?)?;
|
||||
let current = self.get_required_entry(key).await?;
|
||||
ensure_unlocked(¤t)?;
|
||||
if current.modify_index != expected {
|
||||
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be deleted".to_string());
|
||||
}
|
||||
let mut url = self.kv_url(key)?;
|
||||
self.append_scope(&mut url, false);
|
||||
url.query_pairs_mut().append_pair("cas", &expected.to_string());
|
||||
let response =
|
||||
ensure_success(self.send(Method::DELETE, url, None).await?, "delete KV key", self.token()).await?;
|
||||
let deleted = decode_text_response(response, "delete KV key").await?;
|
||||
if deleted.trim() != "true" {
|
||||
return Err("CONSUL_CAS_CONFLICT: The Consul KV key changed before it could be deleted".to_string());
|
||||
}
|
||||
Ok(KvDeleteResponse { deleted: 1, revision: None })
|
||||
}
|
||||
|
||||
async fn get_required_entry(&self, key: &str) -> Result<ConsulKvEntry, String> {
|
||||
let mut url = self.kv_url(key)?;
|
||||
self.append_scope(&mut url, true);
|
||||
let response = self.send(Method::GET, url, None).await?;
|
||||
if is_consul_kv_not_found(&response) {
|
||||
return Err("CONSUL_KEY_NOT_FOUND: The Consul KV key no longer exists".to_string());
|
||||
}
|
||||
let response = ensure_success(response, "read KV metadata", self.token()).await?;
|
||||
decode_json_response::<Vec<ConsulKvEntry>>(response, "read KV metadata")
|
||||
.await?
|
||||
.pop()
|
||||
.ok_or_else(|| "CONSUL_KEY_NOT_FOUND: Consul returned an empty KV response".to_string())
|
||||
}
|
||||
|
||||
fn kv_url(&self, key: &str) -> Result<Url, String> {
|
||||
let encoded = key
|
||||
.split('/')
|
||||
.map(|segment| utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
self.api_url(&format!("/v1/kv/{encoded}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_list_prefix_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
prefix: &str,
|
||||
limit: usize,
|
||||
continuation: Option<&str>,
|
||||
) -> Result<KvListPrefixResponse, String> {
|
||||
client_for_state(state, connection_id).await?.list_prefix(prefix, limit, continuation).await
|
||||
}
|
||||
|
||||
pub async fn consul_list_recursive_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
prefix: &str,
|
||||
max_entries: usize,
|
||||
max_value_bytes: usize,
|
||||
) -> Result<ConsulRecursiveListResponse, String> {
|
||||
client_for_state(state, connection_id).await?.list_recursive(prefix, max_entries, max_value_bytes).await
|
||||
}
|
||||
|
||||
pub async fn consul_get_core(state: &AppState, connection_id: &str, key: &str) -> Result<KvGetResponse, String> {
|
||||
client_for_state(state, connection_id).await?.get_key(key).await
|
||||
}
|
||||
|
||||
pub async fn consul_put_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
value: KvValue,
|
||||
options: KvPutOptions,
|
||||
) -> Result<KvPutResponse, String> {
|
||||
ensure_writable_core(state, connection_id, "write Consul KV key").await?;
|
||||
client_for_state(state, connection_id).await?.put_key(key, value, options).await
|
||||
}
|
||||
|
||||
pub async fn consul_delete_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
options: KvDeleteOptions,
|
||||
) -> Result<KvDeleteResponse, String> {
|
||||
ensure_writable_core(state, connection_id, "delete Consul KV key").await?;
|
||||
client_for_state(state, connection_id).await?.delete_key(key, options).await
|
||||
}
|
||||
|
||||
pub async fn consul_acquire_lock_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulLockRequest,
|
||||
) -> Result<ConsulLockResponse, String> {
|
||||
ensure_writable_core(state, connection_id, "acquire Consul KV lock").await?;
|
||||
client_for_state(state, connection_id).await?.acquire_lock(request).await
|
||||
}
|
||||
|
||||
pub async fn consul_release_lock_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
session: &str,
|
||||
) -> Result<ConsulLockResponse, String> {
|
||||
ensure_writable_core(state, connection_id, "release Consul KV lock").await?;
|
||||
client_for_state(state, connection_id).await?.release_lock(key, session).await
|
||||
}
|
||||
|
||||
fn required_lock_field<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Err(format!("CONSUL_INVALID_REQUEST: {field} is required"))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_for_key(key: String) -> KvKeySummary {
|
||||
KvKeySummary { key, key_bytes: None, value: None, metadata: empty_metadata() }
|
||||
}
|
||||
|
||||
fn metadata_for_entry(entry: &ConsulKvEntry, value_size: u64) -> KvKeyMetadata {
|
||||
KvKeyMetadata {
|
||||
create_revision: Some(KvInt64(entry.create_index.to_string())),
|
||||
mod_revision: Some(KvInt64(entry.modify_index.to_string())),
|
||||
version: None,
|
||||
lease: None,
|
||||
ttl: None,
|
||||
value_size: Some(value_size),
|
||||
czxid: None,
|
||||
mzxid: None,
|
||||
pzxid: None,
|
||||
ctime: None,
|
||||
mtime: None,
|
||||
cversion: None,
|
||||
aversion: None,
|
||||
ephemeral_owner: None,
|
||||
data_length: None,
|
||||
num_children: None,
|
||||
flags: Some(KvInt64(entry.flags.to_string())),
|
||||
lock_index: Some(KvInt64(entry.lock_index.to_string())),
|
||||
session: entry.session.clone().filter(|session| !session.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_metadata() -> KvKeyMetadata {
|
||||
KvKeyMetadata {
|
||||
create_revision: None,
|
||||
mod_revision: None,
|
||||
version: None,
|
||||
lease: None,
|
||||
ttl: None,
|
||||
value_size: None,
|
||||
czxid: None,
|
||||
mzxid: None,
|
||||
pzxid: None,
|
||||
ctime: None,
|
||||
mtime: None,
|
||||
cversion: None,
|
||||
aversion: None,
|
||||
ephemeral_owner: None,
|
||||
data_length: None,
|
||||
num_children: None,
|
||||
flags: None,
|
||||
lock_index: None,
|
||||
session: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_consul_value(value: Option<&str>) -> Result<Vec<u8>, String> {
|
||||
match value {
|
||||
Some(value) => {
|
||||
STANDARD.decode(value).map_err(|error| format!("Failed to decode Consul KV Base64 value: {error}"))
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_from_bytes(bytes: &[u8]) -> KvValue {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(value) => KvValue { encoding: KvValueEncoding::Utf8, data: value.to_string() },
|
||||
Err(_) => KvValue { encoding: KvValueEncoding::Base64, data: STANDARD.encode(bytes) },
|
||||
}
|
||||
}
|
||||
|
||||
fn value_bytes(value: &KvValue) -> Result<Vec<u8>, String> {
|
||||
match value.encoding {
|
||||
KvValueEncoding::Utf8 => Ok(value.data.as_bytes().to_vec()),
|
||||
KvValueEncoding::Base64 => STANDARD
|
||||
.decode(value.data.chars().filter(|char| !char.is_whitespace()).collect::<String>())
|
||||
.map_err(|error| format!("Invalid Base64 KV value: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(value: &KvInt64) -> Result<u64, String> {
|
||||
value.as_str().parse::<u64>().map_err(|_| format!("Invalid Consul index: {}", value.as_str()))
|
||||
}
|
||||
|
||||
fn ensure_unlocked(entry: &ConsulKvEntry) -> Result<(), String> {
|
||||
if let Some(session) = entry.session.as_deref().filter(|session| !session.is_empty()) {
|
||||
return Err(format!("CONSUL_KEY_LOCKED: The Consul KV key is held by session {session}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_continuation(key: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(key.as_bytes())
|
||||
}
|
||||
|
||||
fn decode_continuation(value: &str) -> Result<String, String> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|_| "Invalid Consul continuation token".to_string())?;
|
||||
String::from_utf8(bytes).map_err(|_| "Invalid Consul continuation token".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn u64_fields_accept_the_full_unsigned_range_only() {
|
||||
assert_eq!(parse_u64(&KvInt64("0".into())).unwrap(), 0);
|
||||
assert_eq!(parse_u64(&KvInt64(u64::MAX.to_string())).unwrap(), u64::MAX);
|
||||
assert!(parse_u64(&KvInt64("-1".into())).is_err());
|
||||
assert!(parse_u64(&KvInt64("18446744073709551616".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_values_round_trip_without_utf8_coercion() {
|
||||
let bytes = [0, 0xff, 0x80, b'a'];
|
||||
let value = value_from_bytes(&bytes);
|
||||
assert_eq!(value.encoding, KvValueEncoding::Base64);
|
||||
assert_eq!(value_bytes(&value).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::super::client::client_for_state;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulConfigEntry {
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub modify_index: u64,
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulConfigEntryApply {
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub expected_modify_index: u64,
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_config_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: &str,
|
||||
) -> Result<Vec<ConsulConfigEntry>, String> {
|
||||
validate_identifier(kind, "kind")?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/config/{}", segment(kind)))?;
|
||||
let values: Vec<serde_json::Value> =
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "list Service Mesh config entries").await?;
|
||||
values.into_iter().map(config_entry_from_raw).collect()
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_config_get_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
) -> Result<ConsulConfigEntry, String> {
|
||||
validate_identifier(kind, "kind")?;
|
||||
validate_identifier(name, "name")?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&config_path(kind, name))?;
|
||||
let value = client.request_json(Method::GET, url, None::<&()>, true, "read Service Mesh config entry").await?;
|
||||
config_entry_from_raw(value)
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_config_apply_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulConfigEntryApply,
|
||||
) -> Result<ConsulConfigEntry, String> {
|
||||
super::super::ensure_writable(state, connection_id, "Service Mesh config entry write").await?;
|
||||
validate_raw_identity(&request)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/config")?;
|
||||
url.query_pairs_mut().append_pair("cas", &request.expected_modify_index.to_string());
|
||||
let applied: bool = client
|
||||
.request_json(Method::PUT, url, Some(&request.raw), false, "apply Service Mesh config entry with CAS")
|
||||
.await?;
|
||||
if !applied {
|
||||
return Err("CONSUL_CAS_CONFLICT: Service Mesh config entry changed after it was loaded".to_string());
|
||||
}
|
||||
consul_mesh_config_get_core(state, connection_id, &request.kind, &request.name).await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_config_delete_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
expected_modify_index: u64,
|
||||
) -> Result<bool, String> {
|
||||
super::super::ensure_writable(state, connection_id, "Service Mesh config entry delete").await?;
|
||||
validate_identifier(kind, "kind")?;
|
||||
validate_identifier(name, "name")?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url(&config_path(kind, name))?;
|
||||
url.query_pairs_mut().append_pair("cas", &expected_modify_index.to_string());
|
||||
let deleted: bool = client
|
||||
.request_json(Method::DELETE, url, None::<&()>, false, "delete Service Mesh config entry with CAS")
|
||||
.await?;
|
||||
if !deleted {
|
||||
return Err("CONSUL_CAS_CONFLICT: Service Mesh config entry changed after it was loaded".to_string());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn config_entry_from_raw(raw: serde_json::Value) -> Result<ConsulConfigEntry, String> {
|
||||
let kind = raw.get("Kind").and_then(serde_json::Value::as_str).unwrap_or_default().to_string();
|
||||
let name = raw.get("Name").and_then(serde_json::Value::as_str).unwrap_or_default().to_string();
|
||||
let modify_index = raw.get("ModifyIndex").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
if kind.is_empty() || name.is_empty() {
|
||||
return Err("CONSUL_INVALID_RESPONSE: config entry is missing Kind or Name".to_string());
|
||||
}
|
||||
Ok(ConsulConfigEntry { kind, name, modify_index, raw })
|
||||
}
|
||||
|
||||
fn validate_raw_identity(request: &ConsulConfigEntryApply) -> Result<(), String> {
|
||||
validate_identifier(&request.kind, "kind")?;
|
||||
validate_identifier(&request.name, "name")?;
|
||||
let object = request.raw.as_object().ok_or("CONSUL_INVALID_REQUEST: config entry raw JSON must be an object")?;
|
||||
if object.get("Kind").and_then(serde_json::Value::as_str) != Some(request.kind.as_str())
|
||||
|| object.get("Name").and_then(serde_json::Value::as_str) != Some(request.name.as_str())
|
||||
{
|
||||
return Err("CONSUL_INVALID_REQUEST: raw JSON Kind and Name must match the selected resource".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_identifier(value: &str, label: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() || value.len() > 256 {
|
||||
Err(format!("CONSUL_INVALID_REQUEST: config entry {label} is invalid"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn segment(value: &str) -> String {
|
||||
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
fn config_path(kind: &str, name: &str) -> String {
|
||||
format!("/v1/config/{}/{}", segment(kind), segment(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn raw_identity_must_match() {
|
||||
let request = ConsulConfigEntryApply {
|
||||
kind: "service-defaults".into(),
|
||||
name: "api".into(),
|
||||
expected_modify_index: 4,
|
||||
raw: serde_json::json!({"Kind":"service-defaults","Name":"other"}),
|
||||
};
|
||||
assert!(validate_raw_identity(&request).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::super::client::client_for_state;
|
||||
use crate::connection::AppState;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulDiscoveryChain {
|
||||
#[serde(default)]
|
||||
pub service_name: String,
|
||||
#[serde(default)]
|
||||
pub namespace: String,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
#[serde(default)]
|
||||
pub datacenter: String,
|
||||
#[serde(default)]
|
||||
pub protocol: String,
|
||||
#[serde(default)]
|
||||
pub start_node: String,
|
||||
#[serde(default)]
|
||||
pub nodes: BTreeMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub targets: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_discovery_chain_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
service: &str,
|
||||
) -> Result<ConsulDiscoveryChain, String> {
|
||||
if service.trim().is_empty() {
|
||||
return Err("CONSUL_INVALID_REQUEST: discovery service is required".into());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let service = percent_encoding::utf8_percent_encode(service, percent_encoding::NON_ALPHANUMERIC);
|
||||
let url = client.api_url(&format!("/v1/discovery-chain/{service}"))?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "compile discovery chain").await
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::super::client::client_for_state;
|
||||
use super::config_entries::{consul_mesh_config_apply_core, ConsulConfigEntry, ConsulConfigEntryApply};
|
||||
use crate::connection::AppState;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulExportedService {
|
||||
#[serde(default)]
|
||||
pub service: String,
|
||||
#[serde(default)]
|
||||
pub namespace: String,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
#[serde(default)]
|
||||
pub consumers: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_exported_services_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<ConsulExportedService>, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/exported-services")?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "list exported services").await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_exported_services_apply_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
name: &str,
|
||||
expected_modify_index: u64,
|
||||
raw: serde_json::Value,
|
||||
) -> Result<ConsulConfigEntry, String> {
|
||||
consul_mesh_config_apply_core(
|
||||
state,
|
||||
connection_id,
|
||||
ConsulConfigEntryApply { kind: "exported-services".into(), name: name.into(), expected_modify_index, raw },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use reqwest::{Method, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::client::client_for_state;
|
||||
use crate::connection::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulIntention {
|
||||
#[serde(rename = "ID", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub source_name: String,
|
||||
#[serde(default)]
|
||||
pub destination_name: String,
|
||||
#[serde(default)]
|
||||
pub source_namespace: String,
|
||||
#[serde(default)]
|
||||
pub destination_namespace: String,
|
||||
#[serde(default)]
|
||||
pub source_partition: String,
|
||||
#[serde(default)]
|
||||
pub destination_partition: String,
|
||||
#[serde(default)]
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub precedence: u64,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulIntentionMatchRequest {
|
||||
pub by: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulIntentionCheckRequest {
|
||||
pub source: String,
|
||||
pub destination: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulIntentionExactRequest {
|
||||
pub source: String,
|
||||
pub destination: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulIntentionCheckResponse {
|
||||
pub allowed: bool,
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intentions_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<ConsulIntention>, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/connect/intentions")?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "list intentions").await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_get_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<ConsulIntention, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/connect/intentions/{}", segment(id)))?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read intention").await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_get_exact_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulIntentionExactRequest,
|
||||
) -> Result<ConsulIntention, String> {
|
||||
validate_exact_request(&request)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/connect/intentions/exact")?;
|
||||
let url = exact_intention_url(url, &request.source, &request.destination);
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read exact intention").await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_upsert_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
item: ConsulIntention,
|
||||
) -> Result<ConsulIntention, String> {
|
||||
super::super::ensure_writable(state, connection_id, "intention write").await?;
|
||||
if item.source_name.trim().is_empty() || item.destination_name.trim().is_empty() {
|
||||
return Err("CONSUL_INVALID_REQUEST: intention SourceName and DestinationName are required".into());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/connect/intentions/exact")?;
|
||||
let url = exact_intention_url(url, &item.source_name, &item.destination_name);
|
||||
let written: bool = client.request_json(Method::PUT, url, Some(&item), false, "upsert intention").await?;
|
||||
if !written {
|
||||
return Err("CONSUL_WRITE_REJECTED: Consul rejected the intention upsert".to_string());
|
||||
}
|
||||
let mut url = client.api_url("/v1/connect/intentions/exact")?;
|
||||
url.query_pairs_mut().append_pair("source", &item.source_name).append_pair("destination", &item.destination_name);
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read upserted intention").await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_delete_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<bool, String> {
|
||||
super::super::ensure_writable(state, connection_id, "intention delete").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/connect/intentions/{}", segment(id)))?;
|
||||
client.send_json(Method::DELETE, url, None::<&()>, false, "delete intention").await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_delete_exact_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulIntentionExactRequest,
|
||||
) -> Result<bool, String> {
|
||||
super::super::ensure_writable(state, connection_id, "exact intention delete").await?;
|
||||
validate_exact_request(&request)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/connect/intentions/exact")?;
|
||||
let url = exact_intention_url(url, &request.source, &request.destination);
|
||||
client.send_json(Method::DELETE, url, None::<&()>, false, "delete exact intention").await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_match_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulIntentionMatchRequest,
|
||||
) -> Result<Vec<ConsulIntention>, String> {
|
||||
if !matches!(request.by.as_str(), "source" | "destination") {
|
||||
return Err("CONSUL_INVALID_REQUEST: intention match 'by' must be source or destination".into());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/connect/intentions/match")?;
|
||||
url.query_pairs_mut().append_pair("by", &request.by).append_pair("name", &request.name);
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "match intentions").await
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_intention_check_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulIntentionCheckRequest,
|
||||
) -> Result<ConsulIntentionCheckResponse, String> {
|
||||
if request.source.trim().is_empty() || request.destination.trim().is_empty() {
|
||||
return Err("CONSUL_INVALID_REQUEST: intention check source and destination are required".into());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/connect/intentions/check")?;
|
||||
url.query_pairs_mut().append_pair("source", &request.source).append_pair("destination", &request.destination);
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "check intention authorization").await
|
||||
}
|
||||
|
||||
fn segment(value: &str) -> String {
|
||||
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn exact_intention_url(mut url: Url, source: &str, destination: &str) -> Url {
|
||||
url.query_pairs_mut().append_pair("source", source).append_pair("destination", destination);
|
||||
url
|
||||
}
|
||||
|
||||
fn validate_exact_request(request: &ConsulIntentionExactRequest) -> Result<(), String> {
|
||||
if request.source.trim().is_empty() || request.destination.trim().is_empty() {
|
||||
return Err("CONSUL_INVALID_REQUEST: exact intention source and destination are required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_upsert_uses_official_method_path_and_query() {
|
||||
let base = Url::parse("https://consul.example/proxy/v1/connect/intentions/exact").unwrap();
|
||||
let url = exact_intention_url(base, "api/web", "payments v2");
|
||||
|
||||
assert_eq!(url.path(), "/proxy/v1/connect/intentions/exact");
|
||||
assert_eq!(url.query(), Some("source=api%2Fweb&destination=payments+v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_read_and_delete_share_the_official_path_and_validate_names() {
|
||||
let base = Url::parse("https://consul.example/proxy/v1/connect/intentions/exact").unwrap();
|
||||
let request = ConsulIntentionExactRequest { source: "api".into(), destination: "db".into() };
|
||||
assert!(validate_exact_request(&request).is_ok());
|
||||
let url = exact_intention_url(base, &request.source, &request.destination);
|
||||
assert_eq!(url.query(), Some("source=api&destination=db"));
|
||||
assert!(validate_exact_request(&ConsulIntentionExactRequest { source: "".into(), destination: "db".into() })
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_endpoint_uses_official_path() {
|
||||
let mut url = Url::parse("https://consul.example/proxy/v1/connect/intentions/check").unwrap();
|
||||
url.query_pairs_mut().append_pair("source", "api").append_pair("destination", "db");
|
||||
|
||||
assert_eq!(url.path(), "/proxy/v1/connect/intentions/check");
|
||||
assert_eq!(url.query(), Some("source=api&destination=db"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod config_entries;
|
||||
mod discovery;
|
||||
mod exported_services;
|
||||
mod intentions;
|
||||
mod peering;
|
||||
|
||||
pub use config_entries::*;
|
||||
pub use discovery::*;
|
||||
pub use exported_services::*;
|
||||
pub use intentions::*;
|
||||
pub use peering::*;
|
||||
@@ -0,0 +1,111 @@
|
||||
use super::super::client::client_for_state;
|
||||
use super::super::ConsulSecret;
|
||||
use crate::connection::AppState;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPeering {
|
||||
#[serde(rename = "ID", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
#[serde(rename = "PeerID", default)]
|
||||
pub peer_id: String,
|
||||
#[serde(default)]
|
||||
pub peer_server_name: String,
|
||||
#[serde(rename = "PeerCAPems", default)]
|
||||
pub peer_ca_pems: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub peer_server_addresses: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub imported_services: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exported_services: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPeeringGenerateRequest {
|
||||
pub peer_name: String,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
#[serde(default)]
|
||||
pub meta: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPeeringToken {
|
||||
pub peering_token: ConsulSecret,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPeeringEstablishRequest {
|
||||
pub peer_name: String,
|
||||
pub peering_token: ConsulSecret,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
}
|
||||
|
||||
pub async fn consul_mesh_peering_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<ConsulPeering>, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/peerings")?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "list peerings").await
|
||||
}
|
||||
pub async fn consul_mesh_peering_get_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
name: &str,
|
||||
) -> Result<ConsulPeering, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/peering/{}", segment(name)))?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read peering").await
|
||||
}
|
||||
pub async fn consul_mesh_peering_generate_token_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulPeeringGenerateRequest,
|
||||
) -> Result<ConsulPeeringToken, String> {
|
||||
super::super::ensure_writable(state, connection_id, "peering token generation").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/peering/token")?;
|
||||
client.request_json(Method::POST, url, Some(&request), false, "generate peering token").await
|
||||
}
|
||||
pub async fn consul_mesh_peering_establish_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulPeeringEstablishRequest,
|
||||
) -> Result<ConsulPeering, String> {
|
||||
super::super::ensure_writable(state, connection_id, "peering establish").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/peering/establish")?;
|
||||
client.request_json(Method::POST, url, Some(&request), false, "establish peering").await
|
||||
}
|
||||
pub async fn consul_mesh_peering_delete_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, String> {
|
||||
super::super::ensure_writable(state, connection_id, "peering delete").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/peering/{}", segment(name)))?;
|
||||
client.send_json(Method::DELETE, url, None::<&()>, false, "delete peering").await?;
|
||||
Ok(true)
|
||||
}
|
||||
fn segment(value: &str) -> String {
|
||||
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
mod acl;
|
||||
mod agent;
|
||||
mod archive;
|
||||
mod blocking;
|
||||
mod bulk;
|
||||
mod capabilities;
|
||||
mod catalog;
|
||||
mod client;
|
||||
mod config;
|
||||
mod enterprise;
|
||||
mod health;
|
||||
mod kv;
|
||||
pub mod mesh;
|
||||
mod operator;
|
||||
mod response;
|
||||
mod search;
|
||||
mod session;
|
||||
mod status;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod tools;
|
||||
mod txn;
|
||||
mod types;
|
||||
|
||||
pub use acl::*;
|
||||
pub use agent::*;
|
||||
pub use archive::{
|
||||
consul_export_bundle_core, consul_import_execute_core, consul_import_preview_core, ConsulBundleEntry,
|
||||
ConsulBundleScope, ConsulExportRequest, ConsulExportScopeKind, ConsulImportConflictPolicy, ConsulImportOperation,
|
||||
ConsulImportOutcome, ConsulImportPreview, ConsulImportPreviewRow, ConsulImportReport, ConsulImportRequest,
|
||||
ConsulImportResultItem, ConsulKvBundle,
|
||||
};
|
||||
pub use blocking::{
|
||||
consul_blocking_query_core, consul_cancel_blocking_core, consul_domain_watch_core, ConsulBlockingRequest,
|
||||
ConsulBlockingResponse, ConsulDomainWatchItems, ConsulDomainWatchRequest, ConsulDomainWatchResponse,
|
||||
ConsulDomainWatchTarget,
|
||||
};
|
||||
pub use bulk::{
|
||||
consul_delete_prefix_execute_core, consul_delete_prefix_preview_core, ConsulDeleteCandidate, ConsulDeleteOutcome,
|
||||
ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulDeletePrefixRequest, ConsulDeleteResultItem,
|
||||
};
|
||||
pub use capabilities::{consul_capabilities_core, ConsulCapabilities, ConsulCapabilityStatus};
|
||||
pub use catalog::*;
|
||||
pub use client::ConsulClient;
|
||||
pub use config::{ConsulAgentTarget, ConsulConfig, ConsulConsistency, ConsulScope};
|
||||
pub use enterprise::*;
|
||||
pub use health::*;
|
||||
pub use kv::{
|
||||
consul_acquire_lock_core, consul_delete_core, consul_get_core, consul_list_prefix_core, consul_list_recursive_core,
|
||||
consul_put_core, consul_release_lock_core, ConsulKvRecord, ConsulLockRequest, ConsulLockResponse,
|
||||
ConsulRecursiveListResponse, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES,
|
||||
};
|
||||
pub use operator::*;
|
||||
pub use response::ConsulResponseMetadata;
|
||||
pub use search::{
|
||||
consul_cancel_search_core, consul_search_core, consul_search_progress_core, ConsulSearchMatch,
|
||||
ConsulSearchProgress, ConsulSearchRequest, ConsulSearchResponse,
|
||||
};
|
||||
pub use session::*;
|
||||
pub use status::*;
|
||||
pub use tools::*;
|
||||
pub use txn::{
|
||||
consul_rename_key_core, consul_txn_core, ConsulTxnError, ConsulTxnKvOperation, ConsulTxnKvResult, ConsulTxnRequest,
|
||||
ConsulTxnResult, ConsulTxnVerb, MAX_TXN_OPERATIONS,
|
||||
};
|
||||
|
||||
async fn ensure_writable(state: &crate::connection::AppState, connection_id: &str, action: &str) -> Result<(), String> {
|
||||
if let Some(name) = crate::query::connection_readonly_name(state, connection_id).await {
|
||||
return Err(format!(
|
||||
"CONSUL_READ_ONLY: connection '{name}' has read-only protection enabled. {action} blocked."
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod contract_tests {
|
||||
#[test]
|
||||
fn low_level_mutations_are_not_public_outside_the_consul_module() {
|
||||
let modules = [
|
||||
(include_str!("kv.rs"), &["acquire_lock", "release_lock", "put_key", "delete_key"] as &[&str]),
|
||||
(include_str!("txn.rs"), &["txn"] as &[&str]),
|
||||
(
|
||||
include_str!("agent.rs"),
|
||||
&[
|
||||
"register_agent_service",
|
||||
"deregister_agent_service",
|
||||
"set_agent_service_maintenance",
|
||||
"register_agent_check",
|
||||
"deregister_agent_check",
|
||||
"update_ttl_check",
|
||||
] as &[&str],
|
||||
),
|
||||
(include_str!("session.rs"), &["create_session", "renew_session", "destroy_session"] as &[&str]),
|
||||
];
|
||||
|
||||
for (source, methods) in modules {
|
||||
for method in methods {
|
||||
assert!(
|
||||
source.contains(&format!("pub(super) async fn {method}")),
|
||||
"{method} must remain module-private so callers cannot bypass Core read-only checks"
|
||||
);
|
||||
assert!(!source.contains(&format!("pub async fn {method}")), "{method} must not be publicly callable");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
|
||||
use super::response::ensure_success;
|
||||
|
||||
const MAX_SNAPSHOT_BYTES: usize = 128 * 1024 * 1024;
|
||||
const MAX_SNAPSHOT_EXPANDED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulOperatorField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulOperatorDocument {
|
||||
pub kind: String,
|
||||
pub fields: Vec<ConsulOperatorField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulOperatorReadKind {
|
||||
AutopilotConfiguration,
|
||||
AutopilotHealth,
|
||||
AutopilotState,
|
||||
RaftConfiguration,
|
||||
Usage,
|
||||
License,
|
||||
Audit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulAutopilotUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_dead_servers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_contact_threshold: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_trailing_logs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min_quorum: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_stabilization_time: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redundancy_zone_tag: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_upgrade_migration: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upgrade_version_tag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSnapshotRestoreRequest {
|
||||
pub snapshot_base64: String,
|
||||
pub target_datacenter: String,
|
||||
pub confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSnapshot {
|
||||
pub data_base64: String,
|
||||
pub size_bytes: usize,
|
||||
pub datacenter: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulRaftWriteRequest {
|
||||
pub server_id: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsulKeyringOperation {
|
||||
Install,
|
||||
Use,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulKeyringWriteRequest {
|
||||
pub operation: ConsulKeyringOperation,
|
||||
pub key: String,
|
||||
pub confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct ConsulKeyringWriteBody<'a> {
|
||||
key: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulLicenseWriteRequest {
|
||||
pub license: String,
|
||||
pub confirmation: String,
|
||||
}
|
||||
|
||||
pub async fn consul_operator_read_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
kind: ConsulOperatorReadKind,
|
||||
) -> Result<ConsulOperatorDocument, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let path = match kind {
|
||||
ConsulOperatorReadKind::AutopilotConfiguration => "/v1/operator/autopilot/configuration",
|
||||
ConsulOperatorReadKind::AutopilotHealth => "/v1/operator/autopilot/health",
|
||||
ConsulOperatorReadKind::AutopilotState => "/v1/operator/autopilot/state",
|
||||
ConsulOperatorReadKind::RaftConfiguration => "/v1/operator/raft/configuration",
|
||||
ConsulOperatorReadKind::Usage => "/v1/operator/usage",
|
||||
ConsulOperatorReadKind::License => "/v1/operator/license",
|
||||
ConsulOperatorReadKind::Audit => "/v1/operator/audit-hash",
|
||||
};
|
||||
let url = client.api_url(path)?;
|
||||
let value: serde_json::Value = if matches!(kind, ConsulOperatorReadKind::Audit) {
|
||||
client
|
||||
.request_json(
|
||||
Method::POST,
|
||||
url,
|
||||
Some(&serde_json::json!({ "Input": "dbx-operator-audit" })),
|
||||
true,
|
||||
"read Consul audit hash",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "read Consul operator state").await?
|
||||
};
|
||||
Ok(document(format!("{kind:?}"), value))
|
||||
}
|
||||
|
||||
pub async fn consul_snapshot_generate_core(state: &AppState, connection_id: &str) -> Result<ConsulSnapshot, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/snapshot")?;
|
||||
client.append_scope(&mut url, true);
|
||||
let mut response =
|
||||
ensure_success(client.send(Method::GET, url, None).await?, "generate Consul snapshot", client.token()).await?;
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) =
|
||||
response.chunk().await.map_err(|error| format!("Failed to read snapshot: {}", error.without_url()))?
|
||||
{
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_SNAPSHOT_BYTES {
|
||||
return Err("CONSUL_SNAPSHOT_TOO_LARGE: Snapshot exceeds 128 MiB".to_string());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(ConsulSnapshot {
|
||||
data_base64: STANDARD.encode(&bytes),
|
||||
size_bytes: bytes.len(),
|
||||
datacenter: client.datacenter().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn consul_snapshot_restore_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulSnapshotRestoreRequest,
|
||||
) -> Result<(), String> {
|
||||
ensure_operator_write(state, connection_id, "snapshot_restore", "restore Consul snapshot").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
validate_datacenter_and_confirmation(
|
||||
&client,
|
||||
&request.target_datacenter,
|
||||
&request.confirmation,
|
||||
&format!("RESTORE SNAPSHOT {}", request.target_datacenter),
|
||||
)?;
|
||||
let snapshot =
|
||||
STANDARD.decode(request.snapshot_base64.trim()).map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: {error}"))?;
|
||||
if snapshot.is_empty() || snapshot.len() > MAX_SNAPSHOT_BYTES {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot is empty or exceeds 128 MiB".to_string());
|
||||
}
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
validate_snapshot_archive(&snapshot)?;
|
||||
Ok::<_, String>(snapshot)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Snapshot validation task failed: {error}"))??;
|
||||
let mut url = client.api_url("/v1/snapshot")?;
|
||||
client.append_scope(&mut url, false);
|
||||
ensure_success(client.send(Method::PUT, url, Some(snapshot)).await?, "restore Consul snapshot", client.token())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn validate_snapshot_archive(snapshot: &[u8]) -> Result<(), String> {
|
||||
if snapshot.len() < 10 || !snapshot.starts_with(&[0x1f, 0x8b, 0x08]) {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot must be a gzip archive generated by Consul".to_string());
|
||||
}
|
||||
|
||||
let decoder = GzDecoder::new(snapshot);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
let mut digests = HashMap::new();
|
||||
let mut checksum_manifest = None;
|
||||
let mut expanded_bytes = 0_u64;
|
||||
let mut entry_count = 0_u8;
|
||||
let entries =
|
||||
archive.entries().map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Invalid snapshot archive: {error}"))?;
|
||||
|
||||
for entry in entries {
|
||||
entry_count = entry_count
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "CONSUL_SNAPSHOT_INVALID: Snapshot contains too many entries".to_string())?;
|
||||
if entry_count > 64 {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot contains too many entries".to_string());
|
||||
}
|
||||
let mut entry = entry.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Invalid snapshot entry: {error}"))?;
|
||||
let size = entry.size();
|
||||
expanded_bytes = expanded_bytes
|
||||
.checked_add(size)
|
||||
.ok_or_else(|| "CONSUL_SNAPSHOT_INVALID: Snapshot expanded size overflow".to_string())?;
|
||||
if expanded_bytes > MAX_SNAPSHOT_EXPANDED_BYTES {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot expands beyond 512 MiB".to_string());
|
||||
}
|
||||
|
||||
let path = entry
|
||||
.path()
|
||||
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Invalid snapshot path: {error}"))?
|
||||
.to_string_lossy()
|
||||
.trim_start_matches("./")
|
||||
.to_string();
|
||||
match path.as_str() {
|
||||
"meta.json" | "state.bin" => {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
let mut bytes_read = 0_u64;
|
||||
loop {
|
||||
let count = entry
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Failed to read {path}: {error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes_read += count as u64;
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
if bytes_read == 0 {
|
||||
return Err(format!("CONSUL_SNAPSHOT_INVALID: Snapshot entry {path} is empty"));
|
||||
}
|
||||
if digests.insert(path.clone(), hex_digest(hasher.finalize().as_slice())).is_some() {
|
||||
return Err(format!("CONSUL_SNAPSHOT_INVALID: Snapshot contains duplicate {path}"));
|
||||
}
|
||||
}
|
||||
"SHA256SUMS" => {
|
||||
if size > 64 * 1024 {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot checksum manifest is too large".to_string());
|
||||
}
|
||||
let mut manifest = Vec::with_capacity(size as usize);
|
||||
entry
|
||||
.read_to_end(&mut manifest)
|
||||
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Failed to read checksum manifest: {error}"))?;
|
||||
if checksum_manifest.replace(manifest).is_some() {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Snapshot contains duplicate SHA256SUMS".to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::io::copy(&mut entry, &mut std::io::sink())
|
||||
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Failed to read snapshot entry: {error}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut decoder = archive.into_inner();
|
||||
std::io::copy(&mut decoder, &mut std::io::sink())
|
||||
.map_err(|error| format!("CONSUL_SNAPSHOT_INVALID: Corrupt gzip stream: {error}"))?;
|
||||
|
||||
let manifest =
|
||||
checksum_manifest.ok_or_else(|| "CONSUL_SNAPSHOT_INVALID: Snapshot is missing SHA256SUMS".to_string())?;
|
||||
let manifest = std::str::from_utf8(&manifest)
|
||||
.map_err(|_| "CONSUL_SNAPSHOT_INVALID: Snapshot checksum manifest is not UTF-8".to_string())?;
|
||||
let expected = parse_checksum_manifest(manifest)?;
|
||||
for required in ["meta.json", "state.bin"] {
|
||||
let actual =
|
||||
digests.get(required).ok_or_else(|| format!("CONSUL_SNAPSHOT_INVALID: Snapshot is missing {required}"))?;
|
||||
let expected = expected
|
||||
.get(required)
|
||||
.ok_or_else(|| format!("CONSUL_SNAPSHOT_INVALID: Checksum manifest is missing {required}"))?;
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
return Err(format!("CONSUL_SNAPSHOT_INVALID: Checksum mismatch for {required}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_checksum_manifest(manifest: &str) -> Result<HashMap<String, String>, String> {
|
||||
let mut checksums = HashMap::new();
|
||||
for line in manifest.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let mut fields = line.split_whitespace();
|
||||
let checksum = fields.next().unwrap_or_default();
|
||||
let path = fields.next().unwrap_or_default().trim_start_matches('*').trim_start_matches("./");
|
||||
if checksum.len() != 64 || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) || path.is_empty() {
|
||||
return Err("CONSUL_SNAPSHOT_INVALID: Malformed SHA256SUMS entry".to_string());
|
||||
}
|
||||
if checksums.insert(path.to_string(), checksum.to_string()).is_some() {
|
||||
return Err(format!("CONSUL_SNAPSHOT_INVALID: Duplicate SHA256SUMS entry for {path}"));
|
||||
}
|
||||
}
|
||||
Ok(checksums)
|
||||
}
|
||||
|
||||
fn hex_digest(digest: &[u8]) -> String {
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
pub async fn consul_autopilot_update_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
update: ConsulAutopilotUpdate,
|
||||
confirmation: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_operator_write(state, connection_id, "autopilot", "update Consul Autopilot").await?;
|
||||
if confirmation != "UPDATE AUTOPILOT" {
|
||||
return Err("CONSUL_CONFIRMATION_REQUIRED: Enter UPDATE AUTOPILOT".to_string());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/operator/autopilot/configuration")?;
|
||||
client.send_json(Method::PUT, url, Some(&update), false, "update Consul Autopilot").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn consul_raft_transfer_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulRaftWriteRequest,
|
||||
) -> Result<(), String> {
|
||||
ensure_operator_write(state, connection_id, "raft", "transfer Consul Raft leadership").await?;
|
||||
let id =
|
||||
request.server_id.filter(|id| !id.is_empty()).ok_or("CONSUL_RAFT_TARGET_REQUIRED: Server ID is required")?;
|
||||
if request.confirmation != id {
|
||||
return Err("CONSUL_CONFIRMATION_REQUIRED: Re-enter the target server ID".to_string());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/operator/raft/transfer-leader")?;
|
||||
url.query_pairs_mut().append_pair("id", &id);
|
||||
client.send_json(Method::POST, url, None::<&()>, false, "transfer Raft leadership").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn consul_raft_remove_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulRaftWriteRequest,
|
||||
) -> Result<(), String> {
|
||||
ensure_operator_write(state, connection_id, "raft", "remove Consul Raft peer").await?;
|
||||
let (key, target) = match (
|
||||
request.server_id.filter(|value| !value.is_empty()),
|
||||
request.address.filter(|value| !value.is_empty()),
|
||||
) {
|
||||
(Some(id), None) => ("id", id),
|
||||
(None, Some(address)) => ("address", address),
|
||||
_ => return Err("CONSUL_RAFT_TARGET_INVALID: Specify exactly one server ID or address".to_string()),
|
||||
};
|
||||
if request.confirmation != target {
|
||||
return Err("CONSUL_CONFIRMATION_REQUIRED: Re-enter the peer identifier".to_string());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/operator/raft/peer")?;
|
||||
url.query_pairs_mut().append_pair(key, &target);
|
||||
client.send_json(Method::DELETE, url, None::<&()>, false, "remove Raft peer").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn consul_keyring_write_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulKeyringWriteRequest,
|
||||
) -> Result<(), String> {
|
||||
ensure_operator_write(state, connection_id, "keyring", "modify Consul gossip keyring").await?;
|
||||
if request.confirmation != "CHANGE KEYRING" {
|
||||
return Err("CONSUL_CONFIRMATION_REQUIRED: Enter CHANGE KEYRING".to_string());
|
||||
}
|
||||
if request.key.trim().is_empty() {
|
||||
return Err("CONSUL_KEYRING_KEY_REQUIRED: Gossip key is required".to_string());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let method = match request.operation {
|
||||
ConsulKeyringOperation::Install => Method::POST,
|
||||
ConsulKeyringOperation::Use => Method::PUT,
|
||||
ConsulKeyringOperation::Remove => Method::DELETE,
|
||||
};
|
||||
let url = client.api_url("/v1/operator/keyring")?;
|
||||
let body = ConsulKeyringWriteBody { key: request.key.trim() };
|
||||
client.send_json(method, url, Some(&body), false, "modify Consul gossip keyring").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn consul_license_write_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulLicenseWriteRequest,
|
||||
) -> Result<(), String> {
|
||||
ensure_operator_write(state, connection_id, "license", "update Consul license").await?;
|
||||
if request.confirmation != "UPDATE LICENSE" {
|
||||
return Err("CONSUL_CONFIRMATION_REQUIRED: Enter UPDATE LICENSE".to_string());
|
||||
}
|
||||
if request.license.trim().is_empty() {
|
||||
return Err("CONSUL_LICENSE_REQUIRED: License is required".to_string());
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/operator/license")?;
|
||||
client.append_scope(&mut url, false);
|
||||
ensure_success(
|
||||
client.send(Method::PUT, url, Some(request.license.into_bytes())).await?,
|
||||
"update Consul license",
|
||||
client.token(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn ensure_operator_write(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
feature: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_writable_core(state, connection_id, action).await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
if !client.operator_feature_enabled(feature) {
|
||||
return Err(format!("CONSUL_OPERATOR_FEATURE_DISABLED: {feature} is disabled by default"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_datacenter_and_confirmation(
|
||||
client: &ConsulClient,
|
||||
target: &str,
|
||||
confirmation: &str,
|
||||
expected: &str,
|
||||
) -> Result<(), String> {
|
||||
if client.datacenter().is_empty() || target != client.datacenter() {
|
||||
return Err("CONSUL_DATACENTER_MISMATCH: Snapshot target must match the configured Datacenter".to_string());
|
||||
}
|
||||
if confirmation != expected {
|
||||
return Err(format!("CONSUL_CONFIRMATION_REQUIRED: Enter {expected}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn document(kind: String, value: serde_json::Value) -> ConsulOperatorDocument {
|
||||
let mut fields = Vec::new();
|
||||
if let Some(object) = value.as_object() {
|
||||
for (name, value) in object {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.contains("secret") || lower.contains("token") || lower == "license" {
|
||||
continue;
|
||||
}
|
||||
let mut value = value.clone();
|
||||
redact_nested_sensitive_fields(&mut value);
|
||||
let mut rendered = match &value {
|
||||
serde_json::Value::String(value) => value.clone(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
rendered.truncate(4096);
|
||||
fields.push(ConsulOperatorField { name: name.clone(), value: rendered });
|
||||
}
|
||||
}
|
||||
ConsulOperatorDocument { kind, fields }
|
||||
}
|
||||
|
||||
fn redact_nested_sensitive_fields(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
object.retain(|name, _| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
!lower.contains("secret") && !lower.contains("token") && lower != "license"
|
||||
});
|
||||
for value in object.values_mut() {
|
||||
redact_nested_sensitive_fields(value);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
redact_nested_sensitive_fields(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn operator_document_drops_sensitive_fields() {
|
||||
let result = document(
|
||||
"license".to_string(),
|
||||
serde_json::json!({
|
||||
"SecretID":"top-secret",
|
||||
"Healthy":true,
|
||||
"Config": { "PeeringToken": "nested-secret", "Mode": "safe" }
|
||||
}),
|
||||
);
|
||||
assert_eq!(result.fields.len(), 2);
|
||||
assert!(result.fields.iter().any(|field| field.name == "Healthy"));
|
||||
let serialized = serde_json::to_string(&result).unwrap();
|
||||
assert!(!serialized.contains("top-secret"));
|
||||
assert!(!serialized.contains("nested-secret"));
|
||||
assert!(serialized.contains("safe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyring_body_uses_the_official_pascal_case_field() {
|
||||
let body = ConsulKeyringWriteBody { key: "sensitive-gossip-key" };
|
||||
assert_eq!(serde_json::to_value(body).unwrap(), serde_json::json!({ "Key": "sensitive-gossip-key" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preflight_requires_a_gzip_archive() {
|
||||
assert!(validate_snapshot_archive(b"not-a-snapshot").is_err());
|
||||
let valid = snapshot_fixture(b"raft-state");
|
||||
assert!(validate_snapshot_archive(&valid).is_ok());
|
||||
|
||||
let mut truncated = valid.clone();
|
||||
truncated.truncate(truncated.len() - 4);
|
||||
assert!(validate_snapshot_archive(&truncated).is_err());
|
||||
|
||||
let invalid_checksum = snapshot_fixture_with_checksum(b"raft-state", "0".repeat(64));
|
||||
assert!(validate_snapshot_archive(&invalid_checksum).is_err());
|
||||
}
|
||||
|
||||
fn snapshot_fixture(state: &[u8]) -> Vec<u8> {
|
||||
snapshot_fixture_with_checksum(state, hex_digest(Sha256::digest(state).as_slice()))
|
||||
}
|
||||
|
||||
fn snapshot_fixture_with_checksum(state: &[u8], state_checksum: String) -> Vec<u8> {
|
||||
let metadata = br#"{"ID":"fixture"}"#;
|
||||
let manifest =
|
||||
format!("{} meta.json\n{} state.bin\n", hex_digest(Sha256::digest(metadata).as_slice()), state_checksum);
|
||||
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut archive = tar::Builder::new(encoder);
|
||||
for (path, contents) in
|
||||
[("meta.json", metadata.as_slice()), ("state.bin", state), ("SHA256SUMS", manifest.as_bytes())]
|
||||
{
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(0o600);
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, path, contents).unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::KvInt64;
|
||||
|
||||
const MAX_ERROR_BYTES: usize = 4096;
|
||||
pub(super) const MAX_RESPONSE_BYTES: usize = 48 * 1024 * 1024;
|
||||
pub(super) const MAX_COLLECTION_ITEMS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulResponseMetadata {
|
||||
pub index: Option<KvInt64>,
|
||||
pub filtered_by_acls: Option<bool>,
|
||||
pub known_leader: Option<bool>,
|
||||
pub last_contact: Option<KvInt64>,
|
||||
pub query_backend: Option<String>,
|
||||
}
|
||||
|
||||
impl ConsulResponseMetadata {
|
||||
pub fn from_response(response: &reqwest::Response) -> Self {
|
||||
Self {
|
||||
index: response
|
||||
.headers()
|
||||
.get("X-Consul-Index")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| KvInt64(value.to_string())),
|
||||
filtered_by_acls: response
|
||||
.headers()
|
||||
.get("X-Consul-Results-Filtered-By-ACLs")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.eq_ignore_ascii_case("true")),
|
||||
known_leader: header(response, "X-Consul-KnownLeader").map(|value| value.eq_ignore_ascii_case("true")),
|
||||
last_contact: header(response, "X-Consul-LastContact").map(KvInt64),
|
||||
query_backend: header(response, "X-Consul-Query-Backend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn header(response: &reqwest::Response, name: &str) -> Option<String> {
|
||||
response.headers().get(name).and_then(|value| value.to_str().ok()).map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_success(
|
||||
response: reqwest::Response,
|
||||
action: &str,
|
||||
token: &str,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status();
|
||||
let code = match status {
|
||||
StatusCode::UNAUTHORIZED => "CONSUL_AUTH_REQUIRED",
|
||||
StatusCode::FORBIDDEN => "CONSUL_PERMISSION_DENIED",
|
||||
StatusCode::NOT_FOUND if is_consul_kv_not_found(&response) => "CONSUL_KEY_NOT_FOUND",
|
||||
StatusCode::NOT_FOUND => "CONSUL_REQUEST_FAILED",
|
||||
StatusCode::TOO_MANY_REQUESTS => "CONSUL_RATE_LIMITED",
|
||||
_ if status.is_server_error() => "CONSUL_SERVER_ERROR",
|
||||
_ => "CONSUL_REQUEST_FAILED",
|
||||
};
|
||||
let detail = limited_response_text(response).await;
|
||||
let detail = redact_sensitive(&detail, token);
|
||||
Err(format!("{code}: Failed to {action} ({status}): {}", detail.trim()))
|
||||
}
|
||||
|
||||
pub(super) async fn decode_json_response<T: DeserializeOwned>(
|
||||
response: reqwest::Response,
|
||||
action: &str,
|
||||
) -> Result<T, String> {
|
||||
let body = read_bounded_response(response, action).await?;
|
||||
decode_json_body(&body, action)
|
||||
}
|
||||
|
||||
pub(super) async fn decode_text_response(response: reqwest::Response, action: &str) -> Result<String, String> {
|
||||
let body = read_bounded_response(response, action).await?;
|
||||
String::from_utf8(body)
|
||||
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Failed to decode {action} response as UTF-8: {error}"))
|
||||
}
|
||||
|
||||
pub(super) async fn read_bounded_response(mut response: reqwest::Response, action: &str) -> Result<Vec<u8>, String> {
|
||||
if response.content_length().is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) {
|
||||
return Err(response_size_error(action));
|
||||
}
|
||||
let capacity = response.content_length().unwrap_or(0).min(MAX_RESPONSE_BYTES as u64) as usize;
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
while let Some(chunk) = response.chunk().await.map_err(|error| {
|
||||
format!("CONSUL_RESPONSE_READ_FAILED: Failed to read {action} response: {}", error.without_url())
|
||||
})? {
|
||||
append_bounded_chunk(&mut body, &chunk, MAX_RESPONSE_BYTES).map_err(|_| response_size_error(action))?;
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(super) fn decode_json_body<T: DeserializeOwned>(body: &[u8], action: &str) -> Result<T, String> {
|
||||
let value = serde_json::from_slice::<serde_json::Value>(body)
|
||||
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Failed to decode {action} response: {error}"))?;
|
||||
validate_collection_limits(&value, action, "$")?;
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Failed to decode {action} response: {error}"))
|
||||
}
|
||||
|
||||
fn response_size_error(action: &str) -> String {
|
||||
format!(
|
||||
"CONSUL_RESPONSE_TOO_LARGE: {action} response body exceeds the {} MiB limit",
|
||||
MAX_RESPONSE_BYTES / 1024 / 1024
|
||||
)
|
||||
}
|
||||
|
||||
fn append_bounded_chunk(body: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Result<(), ()> {
|
||||
if body.len().saturating_add(chunk.len()) > limit {
|
||||
return Err(());
|
||||
}
|
||||
body.extend_from_slice(chunk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_collection_limits(value: &serde_json::Value, action: &str, path: &str) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::Array(items) => {
|
||||
ensure_collection_limit(items.len(), action, path)?;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
validate_collection_limits(item, action, &format!("{path}[{index}]"))?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(items) => {
|
||||
ensure_collection_limit(items.len(), action, path)?;
|
||||
for (key, item) in items {
|
||||
validate_collection_limits(item, action, &format!("{path}.{}", json_path_key(key)))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_collection_limit(length: usize, action: &str, path: &str) -> Result<(), String> {
|
||||
if length > MAX_COLLECTION_ITEMS {
|
||||
return Err(format!(
|
||||
"CONSUL_RESPONSE_ITEM_LIMIT_EXCEEDED: {action} response collection at {path} contains {length} items; maximum is {MAX_COLLECTION_ITEMS}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_path_key(key: &str) -> String {
|
||||
if key.chars().all(|character| character.is_ascii_alphanumeric() || character == '_') {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("[{key:?}]")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redact_sensitive(detail: &str, token: &str) -> String {
|
||||
let mut redacted = if token.is_empty() { detail.to_string() } else { detail.replace(token, "[REDACTED]") };
|
||||
for marker in [
|
||||
"SecretID",
|
||||
"Secret_ID",
|
||||
"Secret-ID",
|
||||
"Secret",
|
||||
"JWT",
|
||||
"BootstrapToken",
|
||||
"Bootstrap_Token",
|
||||
"Bootstrap-Token",
|
||||
"PeeringToken",
|
||||
"Peering_Token",
|
||||
"Peering-Token",
|
||||
"X-Consul-Token",
|
||||
"X_Consul_Token",
|
||||
] {
|
||||
redacted = redact_jsonish_field(&redacted, marker);
|
||||
}
|
||||
redacted
|
||||
}
|
||||
|
||||
fn redact_jsonish_field(value: &str, marker: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut rest = value;
|
||||
let marker = marker.to_ascii_lowercase();
|
||||
while let Some(index) = rest.to_ascii_lowercase().find(&marker) {
|
||||
let (before, after_marker) = rest.split_at(index + marker.len());
|
||||
output.push_str(before);
|
||||
let Some(separator) = after_marker.find([':', '=']) else {
|
||||
rest = after_marker;
|
||||
continue;
|
||||
};
|
||||
output.push_str(&after_marker[..=separator]);
|
||||
let after_separator = &after_marker[separator + 1..];
|
||||
let leading = after_separator.len() - after_separator.trim_start().len();
|
||||
output.push_str(&after_separator[..leading]);
|
||||
output.push_str("[REDACTED]");
|
||||
let value_start = &after_separator[leading..];
|
||||
let end = value_start.find([',', '}', '\n', '\r', '&']).unwrap_or(value_start.len());
|
||||
rest = &value_start[end..];
|
||||
}
|
||||
output.push_str(rest);
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn is_consul_kv_not_found(response: &reqwest::Response) -> bool {
|
||||
response.status() == StatusCode::NOT_FOUND && ConsulResponseMetadata::from_response(response).index.is_some()
|
||||
}
|
||||
|
||||
async fn limited_response_text(mut response: reqwest::Response) -> String {
|
||||
let mut body = Vec::with_capacity(MAX_ERROR_BYTES);
|
||||
while body.len() < MAX_ERROR_BYTES {
|
||||
let Ok(Some(chunk)) = response.chunk().await else {
|
||||
break;
|
||||
};
|
||||
if append_limited_error_chunk(&mut body, &chunk) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&body).into_owned()
|
||||
}
|
||||
|
||||
fn append_limited_error_chunk(body: &mut Vec<u8>, chunk: &[u8]) -> bool {
|
||||
let remaining = MAX_ERROR_BYTES.saturating_sub(body.len());
|
||||
body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
|
||||
chunk.len() >= remaining
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_acl_and_peering_secrets() {
|
||||
let detail = r#"{"SecretID":"acl-secret","peering_token":"peer-secret","bootstrap-token":"bootstrap-secret","nested":{"oidcClientSecret":"oidc-secret"},"message":"bad"}"#;
|
||||
let redacted = redact_sensitive(detail, "connection-token");
|
||||
assert!(!redacted.contains("acl-secret"));
|
||||
assert!(!redacted.contains("peer-secret"));
|
||||
assert!(!redacted.contains("bootstrap-secret"));
|
||||
assert!(!redacted.contains("oidc-secret"));
|
||||
assert!(redacted.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_error_body_to_four_kibibytes_across_chunks() {
|
||||
let mut body = Vec::new();
|
||||
assert!(!append_limited_error_chunk(&mut body, &[b'a'; 3_000]));
|
||||
assert!(append_limited_error_chunk(&mut body, &[b'b'; 3_000]));
|
||||
assert_eq!(body.len(), MAX_ERROR_BYTES);
|
||||
assert_eq!(&body[..3_000], &[b'a'; 3_000]);
|
||||
assert!(body[3_000..].iter().all(|byte| *byte == b'b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_chunk_accepts_exact_limit_and_rejects_next_byte() {
|
||||
let mut body = Vec::new();
|
||||
assert_eq!(append_bounded_chunk(&mut body, b"1234", 4), Ok(()));
|
||||
assert_eq!(body, b"1234");
|
||||
assert_eq!(append_bounded_chunk(&mut body, b"5", 4), Err(()));
|
||||
assert_eq!(body, b"1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_collection_limit_accepts_boundary() {
|
||||
let body = serde_json::to_vec(&vec![0_u8; MAX_COLLECTION_ITEMS]).unwrap();
|
||||
let decoded = decode_json_body::<Vec<u8>>(&body, "test list").unwrap();
|
||||
assert_eq!(decoded.len(), MAX_COLLECTION_ITEMS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_collection_limit_reports_path_and_count() {
|
||||
let value = serde_json::json!({ "items": vec![0_u8; MAX_COLLECTION_ITEMS + 1] });
|
||||
let body = serde_json::to_vec(&value).unwrap();
|
||||
let error = decode_json_body::<serde_json::Value>(&body, "test list").unwrap_err();
|
||||
assert!(error.starts_with("CONSUL_RESPONSE_ITEM_LIMIT_EXCEEDED:"));
|
||||
assert!(error.contains("$.items"));
|
||||
assert!(error.contains("10001 items"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::KvValueEncoding;
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::client_for_state;
|
||||
use super::kv::{ConsulKvRecord, MAX_RECURSIVE_ENTRIES, MAX_RECURSIVE_VALUE_BYTES};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSearchRequest {
|
||||
pub request_id: String,
|
||||
pub prefix: String,
|
||||
pub query: String,
|
||||
pub search_keys: bool,
|
||||
pub search_values: bool,
|
||||
pub case_sensitive: bool,
|
||||
pub limit: usize,
|
||||
pub max_scan: usize,
|
||||
#[serde(default)]
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSearchMatch {
|
||||
#[serde(flatten)]
|
||||
pub record: ConsulKvRecord,
|
||||
pub matches_key: bool,
|
||||
pub matches_value: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSearchResponse {
|
||||
pub matches: Vec<ConsulSearchMatch>,
|
||||
pub scanned: usize,
|
||||
pub matched: usize,
|
||||
pub limited: bool,
|
||||
pub filtered_by_acls: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSearchProgress {
|
||||
pub running: bool,
|
||||
pub scanned: usize,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SearchControl {
|
||||
cancelled: AtomicBool,
|
||||
scanned: AtomicUsize,
|
||||
}
|
||||
|
||||
static SEARCHES: OnceLock<Mutex<HashMap<String, Arc<SearchControl>>>> = OnceLock::new();
|
||||
|
||||
struct SearchRegistration {
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl Drop for SearchRegistration {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut searches) = searches().lock() {
|
||||
searches.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_search_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulSearchRequest,
|
||||
) -> Result<ConsulSearchResponse, String> {
|
||||
let query = request.query.trim();
|
||||
if query.is_empty() {
|
||||
return Err("CONSUL_SEARCH_QUERY_REQUIRED: Search query cannot be empty".to_string());
|
||||
}
|
||||
if !request.search_keys && !request.search_values {
|
||||
return Err("CONSUL_SEARCH_SCOPE_REQUIRED: Enable Key or Value search".to_string());
|
||||
}
|
||||
let request_id = request.request_id.trim();
|
||||
if request_id.is_empty() || request_id.len() > 128 {
|
||||
return Err("CONSUL_SEARCH_REQUEST_INVALID: Search request ID is invalid".to_string());
|
||||
}
|
||||
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let scope = client.scope();
|
||||
let key = search_key(connection_id, &scope, request.generation, request_id);
|
||||
let control = Arc::new(SearchControl::default());
|
||||
{
|
||||
let mut registry = searches().lock().map_err(|_| "Consul search registry is unavailable".to_string())?;
|
||||
if registry.contains_key(&key) {
|
||||
return Err(
|
||||
"CONSUL_OPERATION_ALREADY_RUNNING: A search with this request ID is already running".to_string()
|
||||
);
|
||||
}
|
||||
registry.insert(key.clone(), Arc::clone(&control));
|
||||
}
|
||||
let _registration = SearchRegistration { key };
|
||||
|
||||
let max_scan = request.max_scan.clamp(1, MAX_RECURSIVE_ENTRIES);
|
||||
let check = |scanned: usize| {
|
||||
control.scanned.store(scanned, Ordering::Relaxed);
|
||||
if control.cancelled.load(Ordering::Relaxed) {
|
||||
Err("CONSUL_SEARCH_CANCELLED: Consul KV search was cancelled".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let recursive =
|
||||
client.list_recursive_with_control(&request.prefix, max_scan, MAX_RECURSIVE_VALUE_BYTES, Some(&check)).await?;
|
||||
|
||||
let normalized_query = normalize(query, request.case_sensitive);
|
||||
let limit = request.limit.clamp(1, 1000);
|
||||
let mut matched = 0usize;
|
||||
let mut matches = Vec::new();
|
||||
for (index, record) in recursive.entries.into_iter().enumerate() {
|
||||
check(index + 1)?;
|
||||
let matches_key =
|
||||
request.search_keys && normalize(&record.key, request.case_sensitive).contains(&normalized_query);
|
||||
let matches_value = request.search_values
|
||||
&& matches!(record.value.encoding, KvValueEncoding::Utf8)
|
||||
&& normalize(&record.value.data, request.case_sensitive).contains(&normalized_query);
|
||||
if matches_key || matches_value {
|
||||
matched += 1;
|
||||
if matches.len() < limit {
|
||||
matches.push(ConsulSearchMatch { record, matches_key, matches_value });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ConsulSearchResponse {
|
||||
matches,
|
||||
scanned: control.scanned.load(Ordering::Relaxed),
|
||||
matched,
|
||||
limited: matched > limit,
|
||||
filtered_by_acls: recursive.filtered_by_acls,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn consul_search_progress_core(
|
||||
connection_id: &str,
|
||||
scope: &super::ConsulScope,
|
||||
generation: u64,
|
||||
request_id: &str,
|
||||
) -> ConsulSearchProgress {
|
||||
let control = searches()
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|searches| searches.get(&search_key(connection_id, scope, generation, request_id)).cloned());
|
||||
match control {
|
||||
Some(control) => ConsulSearchProgress {
|
||||
running: true,
|
||||
scanned: control.scanned.load(Ordering::Relaxed),
|
||||
cancelled: control.cancelled.load(Ordering::Relaxed),
|
||||
},
|
||||
None => ConsulSearchProgress { running: false, scanned: 0, cancelled: false },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn consul_cancel_search_core(
|
||||
connection_id: &str,
|
||||
scope: &super::ConsulScope,
|
||||
generation: u64,
|
||||
request_id: &str,
|
||||
) -> bool {
|
||||
let control = searches()
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|searches| searches.get(&search_key(connection_id, scope, generation, request_id)).cloned());
|
||||
if let Some(control) = control {
|
||||
control.cancelled.store(true, Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn searches() -> &'static Mutex<HashMap<String, Arc<SearchControl>>> {
|
||||
SEARCHES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn search_key(connection_id: &str, scope: &super::ConsulScope, generation: u64, request_id: &str) -> String {
|
||||
format!(
|
||||
"{connection_id}\0{}\0{}\0{}\0{generation}\0{request_id}",
|
||||
scope.datacenter, scope.partition, scope.namespace
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize(value: &str, case_sensitive: bool) -> String {
|
||||
if case_sensitive {
|
||||
value.to_string()
|
||||
} else {
|
||||
value.to_lowercase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::catalog::{encode_segment, ConsulListResponse, ConsulReadOptions};
|
||||
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
|
||||
use super::kv::ConsulKvRecord;
|
||||
use super::response::{decode_json_response, decode_text_response, ensure_success};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulSession {
|
||||
#[serde(default, rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub lock_delay: u64,
|
||||
#[serde(default)]
|
||||
pub behavior: String,
|
||||
#[serde(default, rename = "TTL", alias = "Ttl")]
|
||||
pub ttl: String,
|
||||
#[serde(default, rename = "NodeChecks", alias = "Checks", deserialize_with = "deserialize_null_default")]
|
||||
pub node_checks: Vec<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_null_default")]
|
||||
pub service_checks: Vec<ConsulSessionServiceCheck>,
|
||||
#[serde(default)]
|
||||
pub namespace: String,
|
||||
#[serde(default)]
|
||||
pub partition: String,
|
||||
#[serde(default)]
|
||||
pub create_index: u64,
|
||||
#[serde(default)]
|
||||
pub modify_index: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulSessionServiceCheck {
|
||||
#[serde(default, rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSessionCreateRequest {
|
||||
pub name: String,
|
||||
pub node: String,
|
||||
pub lock_delay: Option<String>,
|
||||
pub behavior: String,
|
||||
pub ttl: Option<String>,
|
||||
#[serde(default, alias = "checks")]
|
||||
pub node_checks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub service_checks: Vec<ConsulSessionServiceCheck>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSessionHeldKey {
|
||||
pub key: String,
|
||||
pub modify_index: crate::agent_kv::KvInt64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSessionKeysResponse {
|
||||
pub items: Vec<ConsulKvRecord>,
|
||||
pub complete: bool,
|
||||
pub filtered_by_acls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSessionDestroyImpact {
|
||||
pub session: ConsulSession,
|
||||
pub held_keys: Vec<ConsulSessionHeldKey>,
|
||||
pub complete: bool,
|
||||
pub filtered_by_acls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulSessionDestroyRequest {
|
||||
pub id: String,
|
||||
pub expected_behavior: String,
|
||||
pub expected_held_keys: Vec<ConsulSessionHeldKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct SessionCreateResponse {
|
||||
#[serde(rename = "ID", alias = "Id")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub async fn sessions(
|
||||
&self,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
|
||||
self.read_list("/v1/session/list", options, "list Consul sessions").await
|
||||
}
|
||||
|
||||
pub async fn node_sessions(
|
||||
&self,
|
||||
node: &str,
|
||||
options: &ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
|
||||
self.read_list(
|
||||
&format!("/v1/session/node/{}", encode_segment(required(node, "node")?)),
|
||||
options,
|
||||
"list Consul node sessions",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn session(&self, id: &str) -> Result<Option<ConsulSession>, String> {
|
||||
let mut url = self.api_url(&format!("/v1/session/info/{}", encode_segment(required(id, "session id")?)))?;
|
||||
self.append_scope(&mut url, true);
|
||||
let response =
|
||||
ensure_success(self.send(Method::GET, url, None).await?, "read Consul session", self.token()).await?;
|
||||
let mut items = decode_json_response::<Vec<ConsulSession>>(response, "read Consul session").await?;
|
||||
Ok(items.pop())
|
||||
}
|
||||
|
||||
pub(super) async fn create_session(&self, request: ConsulSessionCreateRequest) -> Result<ConsulSession, String> {
|
||||
let behavior = request.behavior.trim().to_ascii_lowercase();
|
||||
if !matches!(behavior.as_str(), "release" | "delete") {
|
||||
return Err("CONSUL_INVALID_REQUEST: Session behavior must be release or delete".to_string());
|
||||
}
|
||||
let ttl = validate_session_ttl(request.ttl.as_deref())?;
|
||||
let lock_delay = validate_lock_delay(request.lock_delay.as_deref())?;
|
||||
let mut url = self.api_url("/v1/session/create")?;
|
||||
self.append_scope(&mut url, false);
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"Name": request.name,
|
||||
"Node": request.node,
|
||||
"LockDelay": lock_delay,
|
||||
"Behavior": behavior,
|
||||
"TTL": ttl,
|
||||
"NodeChecks": request.node_checks,
|
||||
"ServiceChecks": request.service_checks,
|
||||
}))
|
||||
.map_err(|error| format!("Failed to encode Consul session: {error}"))?;
|
||||
let response =
|
||||
ensure_success(self.send(Method::PUT, url, Some(body)).await?, "create Consul session", self.token())
|
||||
.await?;
|
||||
let created = decode_json_response::<SessionCreateResponse>(response, "create Consul session").await?;
|
||||
self.session(&created.id)
|
||||
.await?
|
||||
.ok_or_else(|| "CONSUL_SESSION_NOT_FOUND: Created session was not returned by Consul".to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn renew_session(&self, id: &str) -> Result<ConsulSession, String> {
|
||||
let mut url = self.api_url(&format!("/v1/session/renew/{}", encode_segment(required(id, "session id")?)))?;
|
||||
self.append_scope(&mut url, false);
|
||||
let response =
|
||||
ensure_success(self.send(Method::PUT, url, None).await?, "renew Consul session", self.token()).await?;
|
||||
let mut items = decode_json_response::<Vec<ConsulSession>>(response, "renew Consul session").await?;
|
||||
items.pop().ok_or_else(|| "CONSUL_SESSION_NOT_FOUND: Session no longer exists".to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn destroy_session(&self, id: &str) -> Result<bool, String> {
|
||||
let mut url = self.api_url(&format!("/v1/session/destroy/{}", encode_segment(required(id, "session id")?)))?;
|
||||
self.append_scope(&mut url, false);
|
||||
let response =
|
||||
ensure_success(self.send(Method::PUT, url, None).await?, "destroy Consul session", self.token()).await?;
|
||||
let result = decode_text_response(response, "destroy Consul session").await?;
|
||||
if result.trim() != "true" {
|
||||
return Err("CONSUL_SESSION_CONFLICT: Consul refused to destroy the session".to_string());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn session_keys(&self, id: &str) -> Result<ConsulSessionKeysResponse, String> {
|
||||
let id = required(id, "session id")?;
|
||||
let response = self.list_recursive("", 10_000, 32 * 1024 * 1024).await?;
|
||||
let filtered_by_acls = response.filtered_by_acls == Some(true);
|
||||
Ok(ConsulSessionKeysResponse {
|
||||
items: response.entries.into_iter().filter(|entry| entry.session.as_deref() == Some(id)).collect(),
|
||||
complete: response.complete && !filtered_by_acls,
|
||||
filtered_by_acls,
|
||||
})
|
||||
}
|
||||
|
||||
async fn session_destroy_impact(&self, id: &str) -> Result<ConsulSessionDestroyImpact, String> {
|
||||
let session =
|
||||
self.session(id).await?.ok_or_else(|| "CONSUL_SESSION_NOT_FOUND: Session no longer exists".to_string())?;
|
||||
let recursive = self.list_recursive("", 10_000, 32 * 1024 * 1024).await?;
|
||||
let mut held_keys = recursive
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.session.as_deref() == Some(session.id.as_str()))
|
||||
.map(|entry| ConsulSessionHeldKey { key: entry.key, modify_index: entry.modify_index })
|
||||
.collect::<Vec<_>>();
|
||||
held_keys.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
let filtered_by_acls = recursive.filtered_by_acls == Some(true);
|
||||
Ok(ConsulSessionDestroyImpact {
|
||||
session,
|
||||
held_keys,
|
||||
complete: recursive.complete && !filtered_by_acls,
|
||||
filtered_by_acls,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn required<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Err(format!("CONSUL_INVALID_REQUEST: {field} is required"))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: Deserialize<'de> + Default,
|
||||
{
|
||||
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn validate_session_ttl(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(value) = normalized_duration(value) else { return Ok(None) };
|
||||
let milliseconds = parse_consul_duration_ms(&value, "Session TTL")?;
|
||||
if !(10_000.0..=86_400_000.0).contains(&milliseconds) {
|
||||
return Err("CONSUL_INVALID_REQUEST: Session TTL must be between 10s and 24h".to_string());
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn validate_lock_delay(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(value) = normalized_duration(value) else { return Ok(None) };
|
||||
let milliseconds = parse_consul_duration_ms(&value, "Session LockDelay")?;
|
||||
if !(0.0..=60_000.0).contains(&milliseconds) {
|
||||
return Err("CONSUL_INVALID_REQUEST: Session LockDelay must be between 0s and 60s".to_string());
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn normalized_duration(value: Option<&str>) -> Option<String> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string)
|
||||
}
|
||||
|
||||
fn parse_consul_duration_ms(value: &str, field: &str) -> Result<f64, String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut offset = 0;
|
||||
let mut total = 0.0;
|
||||
while offset < bytes.len() {
|
||||
let start = offset;
|
||||
let mut dots = 0;
|
||||
while offset < bytes.len() && (bytes[offset].is_ascii_digit() || bytes[offset] == b'.') {
|
||||
if bytes[offset] == b'.' {
|
||||
dots += 1;
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
if offset == start || dots > 1 {
|
||||
return Err(format!("CONSUL_INVALID_REQUEST: {field} must use duration units ms, s, m, or h"));
|
||||
}
|
||||
let amount = value[start..offset]
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("CONSUL_INVALID_REQUEST: {field} is not a valid duration"))?;
|
||||
let multiplier = if value[offset..].starts_with("ms") {
|
||||
offset += 2;
|
||||
1.0
|
||||
} else if offset < bytes.len() {
|
||||
let multiplier = match bytes[offset] {
|
||||
b's' => 1_000.0,
|
||||
b'm' => 60_000.0,
|
||||
b'h' => 3_600_000.0,
|
||||
_ => return Err(format!("CONSUL_INVALID_REQUEST: {field} must use duration units ms, s, m, or h")),
|
||||
};
|
||||
offset += 1;
|
||||
multiplier
|
||||
} else {
|
||||
return Err(format!("CONSUL_INVALID_REQUEST: {field} must include a duration unit"));
|
||||
};
|
||||
total += amount * multiplier;
|
||||
}
|
||||
if !total.is_finite() {
|
||||
return Err(format!("CONSUL_INVALID_REQUEST: {field} is not a valid duration"));
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub async fn consul_sessions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
|
||||
client_for_state(state, connection_id).await?.sessions(&options).await
|
||||
}
|
||||
|
||||
pub async fn consul_node_sessions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
options: ConsulReadOptions,
|
||||
) -> Result<ConsulListResponse<Vec<ConsulSession>>, String> {
|
||||
client_for_state(state, connection_id).await?.node_sessions(node, &options).await
|
||||
}
|
||||
|
||||
pub async fn consul_session_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<ConsulSession>, String> {
|
||||
client_for_state(state, connection_id).await?.session(id).await
|
||||
}
|
||||
|
||||
pub async fn consul_session_keys_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<ConsulSessionKeysResponse, String> {
|
||||
client_for_state(state, connection_id).await?.session_keys(id).await
|
||||
}
|
||||
|
||||
pub async fn consul_session_destroy_impact_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<ConsulSessionDestroyImpact, String> {
|
||||
client_for_state(state, connection_id).await?.session_destroy_impact(id).await
|
||||
}
|
||||
|
||||
pub async fn consul_create_session_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulSessionCreateRequest,
|
||||
) -> Result<ConsulSession, String> {
|
||||
ensure_writable_core(state, connection_id, "create Consul session").await?;
|
||||
client_for_state(state, connection_id).await?.create_session(request).await
|
||||
}
|
||||
|
||||
pub async fn consul_renew_session_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<ConsulSession, String> {
|
||||
ensure_writable_core(state, connection_id, "renew Consul session").await?;
|
||||
client_for_state(state, connection_id).await?.renew_session(id).await
|
||||
}
|
||||
|
||||
pub async fn consul_destroy_session_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulSessionDestroyRequest,
|
||||
) -> Result<bool, String> {
|
||||
ensure_writable_core(state, connection_id, "destroy Consul session").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let impact = client.session_destroy_impact(&request.id).await?;
|
||||
if !impact.complete {
|
||||
return Err("CONSUL_IMPACT_INCOMPLETE: Session-held keys could not be enumerated completely".to_string());
|
||||
}
|
||||
validate_destroy_confirmation(&impact, &request)?;
|
||||
client.destroy_session(&request.id).await
|
||||
}
|
||||
|
||||
fn validate_destroy_confirmation(
|
||||
impact: &ConsulSessionDestroyImpact,
|
||||
request: &ConsulSessionDestroyRequest,
|
||||
) -> Result<(), String> {
|
||||
let mut expected_keys = request.expected_held_keys.clone();
|
||||
expected_keys.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
if impact.session.id != request.id
|
||||
|| impact.session.behavior != request.expected_behavior
|
||||
|| impact.held_keys != expected_keys
|
||||
{
|
||||
return Err("CONSUL_SESSION_CONFLICT: Session behavior or held keys changed after confirmation".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::consul::test_support::{serve_once, test_client};
|
||||
|
||||
#[test]
|
||||
fn decodes_consul_two_session_checks_and_indexes() {
|
||||
let session: ConsulSession = serde_json::from_str(
|
||||
r#"{
|
||||
"ID":"session-1",
|
||||
"Name":"demo",
|
||||
"Node":"node-1",
|
||||
"LockDelay":15000000000,
|
||||
"Behavior":"release",
|
||||
"TTL":"",
|
||||
"NodeChecks":["serfHealth"],
|
||||
"ServiceChecks":[{"ID":"service:api:health","Namespace":"team-a"}],
|
||||
"CreateIndex":12,
|
||||
"ModifyIndex":18
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session.id, "session-1");
|
||||
assert_eq!(session.node_checks, vec!["serfHealth"]);
|
||||
assert_eq!(session.service_checks[0].id, "service:api:health");
|
||||
assert_eq!(session.service_checks[0].namespace, "team-a");
|
||||
assert_eq!(session.modify_index, 18);
|
||||
|
||||
let local_dev: ConsulSession =
|
||||
serde_json::from_str(r#"{"ID":"session-2","NodeChecks":["serfHealth"],"ServiceChecks":null}"#).unwrap();
|
||||
assert!(local_dev.service_checks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_legacy_checks_as_node_checks() {
|
||||
let session: ConsulSession = serde_json::from_str(r#"{"ID":"legacy","Checks":["serfHealth"]}"#).unwrap();
|
||||
assert_eq!(session.node_checks, vec!["serfHealth"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_session_duration_boundaries() {
|
||||
assert_eq!(validate_session_ttl(None).unwrap(), None);
|
||||
assert_eq!(validate_session_ttl(Some("10s")).unwrap(), Some("10s".into()));
|
||||
assert_eq!(validate_session_ttl(Some("24h")).unwrap(), Some("24h".into()));
|
||||
assert!(validate_session_ttl(Some("9.999s")).is_err());
|
||||
assert!(validate_session_ttl(Some("24h1s")).is_err());
|
||||
assert_eq!(validate_lock_delay(Some("0s")).unwrap(), Some("0s".into()));
|
||||
assert_eq!(validate_lock_delay(Some("1m")).unwrap(), Some("1m".into()));
|
||||
assert!(validate_lock_delay(Some("60s1ms")).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renew_uses_put_encoded_id_scope_and_acl_header() {
|
||||
let body = r#"[{"ID":"session/a","TTL":"30s","Behavior":"release"}]"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let (url, request_rx) = serve_once(response).await;
|
||||
let client = test_client(url).await;
|
||||
let renewed = client.renew_session("session/a").await.unwrap();
|
||||
|
||||
assert_eq!(renewed.id, "session/a");
|
||||
assert_eq!(renewed.ttl, "30s");
|
||||
let request = request_rx.await.unwrap();
|
||||
let headers = request.split_once("\r\n\r\n").unwrap().0;
|
||||
assert!(headers
|
||||
.starts_with("PUT /proxy/v1/session/renew/session%2Fa?dc=dc1&ns=team-a&partition=partition-a HTTP/1.1"));
|
||||
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_confirmation_rejects_changed_held_keys() {
|
||||
let impact = ConsulSessionDestroyImpact {
|
||||
session: ConsulSession { id: "session-1".into(), behavior: "delete".into(), ..Default::default() },
|
||||
held_keys: vec![ConsulSessionHeldKey {
|
||||
key: "locks/a".into(),
|
||||
modify_index: crate::agent_kv::KvInt64("4".into()),
|
||||
}],
|
||||
complete: true,
|
||||
filtered_by_acls: false,
|
||||
};
|
||||
let stale = ConsulSessionDestroyRequest {
|
||||
id: "session-1".into(),
|
||||
expected_behavior: "delete".into(),
|
||||
expected_held_keys: Vec::new(),
|
||||
};
|
||||
assert!(validate_destroy_confirmation(&impact, &stale).is_err());
|
||||
let current = ConsulSessionDestroyRequest {
|
||||
id: "session-1".into(),
|
||||
expected_behavior: "delete".into(),
|
||||
expected_held_keys: impact.held_keys.clone(),
|
||||
};
|
||||
assert!(validate_destroy_confirmation(&impact, ¤t).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ConsulClient};
|
||||
use super::response::{decode_json_response, ensure_success};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulStatus {
|
||||
pub leader: String,
|
||||
pub peers: Vec<String>,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub async fn status_leader(&self) -> Result<String, String> {
|
||||
let url = self.api_url("/v1/status/leader")?;
|
||||
let response =
|
||||
ensure_success(self.send(Method::GET, url, None).await?, "read Consul leader", self.token()).await?;
|
||||
decode_json_response(response, "read Consul leader").await
|
||||
}
|
||||
|
||||
pub async fn status_peers(&self) -> Result<Vec<String>, String> {
|
||||
let url = self.api_url("/v1/status/peers")?;
|
||||
let response =
|
||||
ensure_success(self.send(Method::GET, url, None).await?, "read Consul peers", self.token()).await?;
|
||||
decode_json_response(response, "read Consul peers").await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_status_leader_core(state: &AppState, connection_id: &str) -> Result<String, String> {
|
||||
client_for_state(state, connection_id).await?.status_leader().await
|
||||
}
|
||||
|
||||
pub async fn consul_status_peers_core(state: &AppState, connection_id: &str) -> Result<Vec<String>, String> {
|
||||
client_for_state(state, connection_id).await?.status_peers().await
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{ConsulClient, ConsulConfig, ConsulConsistency};
|
||||
|
||||
pub(super) async fn serve_once(response: String) -> (reqwest::Url, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (request_tx, request_rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let count = socket.read(&mut buffer).await.unwrap();
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
let Some(header_end) = find_bytes(&request, b"\r\n\r\n") else {
|
||||
assert!(request.len() <= 1024 * 1024);
|
||||
continue;
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().ok()).flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= header_end + 4 + content_length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = request_tx.send(String::from_utf8(request).unwrap());
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
socket.shutdown().await.unwrap();
|
||||
});
|
||||
(reqwest::Url::parse(&format!("http://{address}/proxy")).unwrap(), request_rx)
|
||||
}
|
||||
|
||||
pub(super) async fn test_client(base_url: reqwest::Url) -> ConsulClient {
|
||||
ConsulClient::new(ConsulConfig {
|
||||
base_url,
|
||||
token: "fixture-token".to_string(),
|
||||
datacenter: "dc1".to_string(),
|
||||
namespace: "team-a".to_string(),
|
||||
partition: "partition-a".to_string(),
|
||||
consistency: ConsulConsistency::Consistent,
|
||||
tls_skip_verify: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
request_timeout_secs: 5,
|
||||
connect_override: None,
|
||||
operator_snapshot_restore_enabled: false,
|
||||
operator_autopilot_write_enabled: false,
|
||||
operator_raft_write_enabled: false,
|
||||
operator_keyring_write_enabled: false,
|
||||
operator_license_write_enabled: false,
|
||||
agent_target: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|window| window == needle)
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::KvInt64;
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
|
||||
|
||||
const MAX_EVENT_PAYLOAD_BYTES: usize = 100;
|
||||
const MAX_EVENT_NAME_BYTES: usize = 256;
|
||||
const MAX_EVENT_FILTER_BYTES: usize = 1_024;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedQueryService {
|
||||
pub service: String,
|
||||
#[serde(default)]
|
||||
pub near: String,
|
||||
#[serde(default)]
|
||||
pub only_passing: bool,
|
||||
#[serde(default, deserialize_with = "deserialize_null_default")]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedQuery {
|
||||
#[serde(rename = "ID", alias = "Id", default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub session: String,
|
||||
pub service: ConsulPreparedQueryService,
|
||||
#[serde(default = "zero_index")]
|
||||
pub create_index: KvInt64,
|
||||
#[serde(default = "zero_index")]
|
||||
pub modify_index: KvInt64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulPreparedQueryInput {
|
||||
pub name: String,
|
||||
pub session: String,
|
||||
pub service: ConsulPreparedQueryService,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct ConsulPreparedQueryWrite<'a> {
|
||||
name: &'a str,
|
||||
session: &'a str,
|
||||
service: &'a ConsulPreparedQueryService,
|
||||
}
|
||||
|
||||
impl ConsulPreparedQueryInput {
|
||||
fn wire(&self) -> ConsulPreparedQueryWrite<'_> {
|
||||
ConsulPreparedQueryWrite { name: &self.name, session: &self.session, service: &self.service }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulPreparedQueryExecuteRequest {
|
||||
pub query: String,
|
||||
pub limit: usize,
|
||||
pub connect: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedQueryNode {
|
||||
pub node: ConsulPreparedNodeIdentity,
|
||||
pub service: ConsulPreparedServiceIdentity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedNodeIdentity {
|
||||
pub node: String,
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub datacenter: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedServiceIdentity {
|
||||
#[serde(rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
pub service: String,
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
#[serde(default, deserialize_with = "deserialize_null_default")]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedQueryExecuteResponse {
|
||||
#[serde(default)]
|
||||
pub service: String,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<ConsulPreparedQueryNode>,
|
||||
#[serde(rename = "DNS", alias = "Dns", default)]
|
||||
pub dns: ConsulPreparedQueryDns,
|
||||
#[serde(default)]
|
||||
pub datacenter: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulPreparedQueryDns {
|
||||
#[serde(rename = "TTL", alias = "Ttl", default)]
|
||||
pub ttl: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct ConsulPreparedQueryExplainResponse {
|
||||
query: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulEventFireRequest {
|
||||
pub name: String,
|
||||
pub payload_base64: String,
|
||||
pub node_filter: String,
|
||||
pub service_filter: String,
|
||||
pub tag_filter: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulEvent {
|
||||
#[serde(rename = "ID", alias = "Id")]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub payload: Option<String>,
|
||||
#[serde(default)]
|
||||
pub node_filter: String,
|
||||
#[serde(default)]
|
||||
pub service_filter: String,
|
||||
#[serde(default)]
|
||||
pub tag_filter: String,
|
||||
#[serde(default = "zero_index")]
|
||||
pub l_time: KvInt64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulCoordinate {
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub segment: String,
|
||||
pub coord: ConsulCoordinateValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ConsulCoordinateValue {
|
||||
#[serde(default)]
|
||||
pub vec: Vec<f64>,
|
||||
pub error: f64,
|
||||
pub adjustment: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
async fn prepared_queries(&self) -> Result<Vec<ConsulPreparedQuery>, String> {
|
||||
let url = self.api_url("/v1/query")?;
|
||||
self.request_json(Method::GET, url, None::<&()>, true, "list prepared queries").await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<ConsulPreparedQuery>, String> {
|
||||
client_for_state(state, connection_id).await?.prepared_queries().await
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_read_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
) -> Result<ConsulPreparedQuery, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
|
||||
client
|
||||
.request_json::<Vec<ConsulPreparedQuery>, _>(Method::GET, url, None::<&()>, true, "read prepared query")
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "CONSUL_QUERY_NOT_FOUND: Prepared query not found".to_string())
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_create_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
input: ConsulPreparedQueryInput,
|
||||
) -> Result<String, String> {
|
||||
ensure_writable_core(state, connection_id, "create Consul prepared query").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/query")?;
|
||||
#[derive(Deserialize)]
|
||||
struct Created {
|
||||
#[serde(rename = "ID")]
|
||||
id: String,
|
||||
}
|
||||
Ok(client
|
||||
.request_json::<Created, _>(Method::POST, url, Some(&input.wire()), false, "create prepared query")
|
||||
.await?
|
||||
.id)
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_update_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
id: &str,
|
||||
input: ConsulPreparedQueryInput,
|
||||
) -> Result<(), String> {
|
||||
ensure_writable_core(state, connection_id, "update Consul prepared query").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let read_url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
|
||||
let mut existing = client
|
||||
.request_json::<Vec<serde_json::Value>, _>(
|
||||
Method::GET,
|
||||
read_url,
|
||||
None::<&()>,
|
||||
true,
|
||||
"read prepared query before update",
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "CONSUL_QUERY_NOT_FOUND: Prepared query not found".to_string())?;
|
||||
apply_prepared_query_patch(&mut existing, &input)?;
|
||||
let url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
|
||||
client.send_json(Method::PUT, url, Some(&existing), false, "update prepared query").await.map(|_| ())
|
||||
}
|
||||
|
||||
fn apply_prepared_query_patch(
|
||||
existing: &mut serde_json::Value,
|
||||
input: &ConsulPreparedQueryInput,
|
||||
) -> Result<(), String> {
|
||||
if input.name.trim().is_empty() || input.service.service.trim().is_empty() {
|
||||
return Err("CONSUL_INVALID_REQUEST: Prepared query name and service are required".to_string());
|
||||
}
|
||||
let object = existing
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: Prepared query is not an object".to_string())?;
|
||||
object.remove("ID");
|
||||
object.remove("CreateIndex");
|
||||
object.remove("ModifyIndex");
|
||||
object.insert("Name".to_string(), serde_json::Value::String(input.name.trim().to_string()));
|
||||
let service = object
|
||||
.entry("Service")
|
||||
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "CONSUL_INVALID_RESPONSE: Prepared query Service is not an object".to_string())?;
|
||||
service.insert("Service".to_string(), serde_json::Value::String(input.service.service.trim().to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_delete_core(state: &AppState, connection_id: &str, id: &str) -> Result<(), String> {
|
||||
ensure_writable_core(state, connection_id, "delete Consul prepared query").await?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/query/{}", encode(id)))?;
|
||||
client.send_json(Method::DELETE, url, None::<&()>, false, "delete prepared query").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_execute_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulPreparedQueryExecuteRequest,
|
||||
) -> Result<ConsulPreparedQueryExecuteResponse, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url(&format!("/v1/query/{}/execute", encode(&request.query)))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("limit", &request.limit.clamp(1, 1000).to_string())
|
||||
.append_pair("connect", if request.connect { "true" } else { "false" });
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "execute prepared query").await
|
||||
}
|
||||
|
||||
pub async fn consul_prepared_query_explain_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
query: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url(&format!("/v1/query/{}/explain", encode(query)))?;
|
||||
let response: ConsulPreparedQueryExplainResponse =
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "explain prepared query").await?;
|
||||
let mut query = response.query;
|
||||
redact_prepared_query_secrets(&mut query);
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
fn redact_prepared_query_secrets(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key.eq_ignore_ascii_case("token") {
|
||||
*value = serde_json::Value::String("****************".to_string());
|
||||
} else {
|
||||
redact_prepared_query_secrets(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
redact_prepared_query_secrets(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn consul_event_list_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
name: Option<&str>,
|
||||
) -> Result<Vec<ConsulEvent>, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url("/v1/event/list")?;
|
||||
if let Some(name) = name.filter(|name| !name.is_empty()) {
|
||||
url.query_pairs_mut().append_pair("name", name);
|
||||
}
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "list Consul events").await
|
||||
}
|
||||
|
||||
pub async fn consul_event_fire_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulEventFireRequest,
|
||||
) -> Result<ConsulEvent, String> {
|
||||
ensure_writable_core(state, connection_id, "fire Consul event").await?;
|
||||
validate_event_text(&request.name, "name", MAX_EVENT_NAME_BYTES)?;
|
||||
if request.name.starts_with('_') {
|
||||
return Err("CONSUL_EVENT_NAME_RESERVED: Event names beginning with '_' are reserved by Consul".to_string());
|
||||
}
|
||||
for (name, value) in [
|
||||
("node filter", request.node_filter.as_str()),
|
||||
("service filter", request.service_filter.as_str()),
|
||||
("tag filter", request.tag_filter.as_str()),
|
||||
] {
|
||||
if value.len() > MAX_EVENT_FILTER_BYTES {
|
||||
return Err(format!("CONSUL_EVENT_FILTER_TOO_LARGE: {name} exceeds {MAX_EVENT_FILTER_BYTES} bytes"));
|
||||
}
|
||||
}
|
||||
let encoded_payload = request.payload_base64.trim();
|
||||
let max_encoded_len = MAX_EVENT_PAYLOAD_BYTES.div_ceil(3) * 4;
|
||||
if encoded_payload.len() > max_encoded_len {
|
||||
return Err(format!("CONSUL_EVENT_PAYLOAD_TOO_LARGE: Event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} bytes"));
|
||||
}
|
||||
let payload = STANDARD.decode(encoded_payload).map_err(|error| format!("CONSUL_EVENT_PAYLOAD_INVALID: {error}"))?;
|
||||
if payload.len() > MAX_EVENT_PAYLOAD_BYTES {
|
||||
return Err(format!("CONSUL_EVENT_PAYLOAD_TOO_LARGE: Event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} bytes"));
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let mut url = client.api_url(&format!("/v1/event/fire/{}", encode(&request.name)))?;
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
if !request.node_filter.is_empty() {
|
||||
query.append_pair("node", &request.node_filter);
|
||||
}
|
||||
if !request.service_filter.is_empty() {
|
||||
query.append_pair("service", &request.service_filter);
|
||||
}
|
||||
if !request.tag_filter.is_empty() {
|
||||
query.append_pair("tag", &request.tag_filter);
|
||||
}
|
||||
}
|
||||
client.append_scope(&mut url, false);
|
||||
let response = client.send(Method::PUT, url, Some(payload)).await?;
|
||||
let response = super::response::ensure_success(response, "fire Consul event", client.token()).await?;
|
||||
super::response::decode_json_response(response, "fire Consul event").await
|
||||
}
|
||||
|
||||
fn validate_event_text(value: &str, field: &str, max_bytes: usize) -> Result<(), String> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("CONSUL_INVALID_REQUEST: Event {field} is required"));
|
||||
}
|
||||
if value.len() > max_bytes {
|
||||
return Err(format!("CONSUL_EVENT_INPUT_TOO_LARGE: Event {field} exceeds {max_bytes} bytes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn consul_coordinate_nodes_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<ConsulCoordinate>, String> {
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let url = client.api_url("/v1/coordinate/nodes")?;
|
||||
client.request_json(Method::GET, url, None::<&()>, true, "list network coordinates").await
|
||||
}
|
||||
|
||||
fn encode(value: &str) -> String {
|
||||
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn zero_index() -> KvInt64 {
|
||||
KvInt64("0".to_string())
|
||||
}
|
||||
|
||||
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: Deserialize<'de> + Default,
|
||||
{
|
||||
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prepared_query_transport_input_maps_to_pascal_case_wire_body() {
|
||||
let input = ConsulPreparedQueryInput {
|
||||
name: "payments".into(),
|
||||
session: "session-1".into(),
|
||||
service: ConsulPreparedQueryService { service: "api".into(), ..Default::default() },
|
||||
};
|
||||
let value = serde_json::to_value(input.wire()).unwrap();
|
||||
assert_eq!(value["Name"], "payments");
|
||||
assert_eq!(value["Session"], "session-1");
|
||||
assert_eq!(value["Service"]["Service"], "api");
|
||||
assert!(value.get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_query_update_preserves_advanced_and_sensitive_fields() {
|
||||
let mut existing = serde_json::json!({
|
||||
"ID": "query-1",
|
||||
"Name": "old",
|
||||
"Token": "keep-secret",
|
||||
"Service": { "Service": "old-api", "Near": "_agent", "Tags": ["v1"], "Failover": { "Datacenters": ["dc2"] } },
|
||||
"DNS": { "TTL": "5s" },
|
||||
"CreateIndex": 10,
|
||||
"ModifyIndex": 11
|
||||
});
|
||||
let input = ConsulPreparedQueryInput {
|
||||
name: "new".into(),
|
||||
session: String::new(),
|
||||
service: ConsulPreparedQueryService { service: "new-api".into(), ..Default::default() },
|
||||
};
|
||||
apply_prepared_query_patch(&mut existing, &input).unwrap();
|
||||
assert_eq!(existing["Name"], "new");
|
||||
assert_eq!(existing["Service"]["Service"], "new-api");
|
||||
assert_eq!(existing["Service"]["Near"], "_agent");
|
||||
assert_eq!(existing["Token"], "keep-secret");
|
||||
assert_eq!(existing["DNS"]["TTL"], "5s");
|
||||
assert!(existing.get("ID").is_none());
|
||||
assert!(existing.get("ModifyIndex").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_validation_rejects_reserved_names_and_oversized_encoded_payloads() {
|
||||
assert!(validate_event_text("", "name", MAX_EVENT_NAME_BYTES).is_err());
|
||||
assert!("_internal".starts_with('_'));
|
||||
let oversized = vec![0_u8; MAX_EVENT_PAYLOAD_BYTES + 3];
|
||||
assert!(STANDARD.encode(oversized).len() > MAX_EVENT_PAYLOAD_BYTES.div_ceil(3) * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_event_response_uses_id_acronym() {
|
||||
let event: ConsulEvent = serde_json::from_value(serde_json::json!({
|
||||
"ID": "7f12d2f4-2cb8-4795-9e5f-f8e35e9912aa",
|
||||
"Name": "deploy",
|
||||
"Payload": "cmVsZWFzZS0x",
|
||||
"NodeFilter": "",
|
||||
"ServiceFilter": "api",
|
||||
"TagFilter": "v1",
|
||||
"Version": 1,
|
||||
"LTime": 42
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(event.id, "7f12d2f4-2cb8-4795-9e5f-f8e35e9912aa");
|
||||
assert_eq!(event.l_time, KvInt64("42".into()));
|
||||
let encoded = serde_json::to_value(event).unwrap();
|
||||
assert_eq!(encoded["ID"], "7f12d2f4-2cb8-4795-9e5f-f8e35e9912aa");
|
||||
assert!(encoded.get("Id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_prepared_query_response_preserves_protocol_acronyms() {
|
||||
let query: ConsulPreparedQuery = serde_json::from_value(serde_json::json!({
|
||||
"ID": "query-1",
|
||||
"Name": "payments",
|
||||
"Session": "",
|
||||
"Service": { "Service": "api", "Near": "", "OnlyPassing": true, "Tags": ["v1"] },
|
||||
"CreateIndex": 10,
|
||||
"ModifyIndex": 11
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(query.id, "query-1");
|
||||
let encoded = serde_json::to_value(query).unwrap();
|
||||
assert_eq!(encoded["ID"], "query-1");
|
||||
assert!(encoded.get("Id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_prepared_query_execute_response_preserves_dns_ttl_and_service_id() {
|
||||
let response: ConsulPreparedQueryExecuteResponse = serde_json::from_value(serde_json::json!({
|
||||
"Service": "api",
|
||||
"Nodes": [{
|
||||
"Node": { "Node": "node-1", "Address": "10.0.0.1", "Datacenter": "dc1" },
|
||||
"Service": { "ID": "api-1", "Service": "api", "Port": 8080, "Tags": null }
|
||||
}],
|
||||
"DNS": { "TTL": "5s" },
|
||||
"Datacenter": "dc1"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(response.nodes[0].service.id, "api-1");
|
||||
assert_eq!(response.nodes[0].service.address, "");
|
||||
assert!(response.nodes[0].service.tags.is_empty());
|
||||
assert_eq!(response.dns.ttl, "5s");
|
||||
let encoded = serde_json::to_value(response).unwrap();
|
||||
assert_eq!(encoded["Nodes"][0]["Service"]["ID"], "api-1");
|
||||
assert_eq!(encoded["DNS"]["TTL"], "5s");
|
||||
assert!(encoded.get("Dns").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_prepared_query_explain_response_unwraps_query_document() {
|
||||
let response: ConsulPreparedQueryExplainResponse = serde_json::from_value(serde_json::json!({
|
||||
"Query": {
|
||||
"ID": "query-1",
|
||||
"Name": "payments",
|
||||
"Session": "",
|
||||
"Token": "secret-management-token",
|
||||
"Template": { "Type": "name_prefix_match", "Regexp": "^payments-(.*)$" },
|
||||
"Service": { "Service": "api", "Near": "", "OnlyPassing": true, "Tags": [] },
|
||||
"DNS": { "TTL": "5s" },
|
||||
"CreateIndex": 10,
|
||||
"ModifyIndex": 11
|
||||
},
|
||||
"Index": 11,
|
||||
"KnownLeader": true
|
||||
}))
|
||||
.unwrap();
|
||||
let mut query = response.query;
|
||||
redact_prepared_query_secrets(&mut query);
|
||||
assert_eq!(query["ID"], "query-1");
|
||||
assert_eq!(query["Service"]["Service"], "api");
|
||||
assert_eq!(query["Template"]["Type"], "name_prefix_match");
|
||||
assert_eq!(query["DNS"]["TTL"], "5s");
|
||||
assert_eq!(query["Token"], "****************");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent_kv::{KvInt64, KvValue, KvValueEncoding};
|
||||
use crate::connection::AppState;
|
||||
|
||||
use super::client::{client_for_state, ensure_writable_core, ConsulClient};
|
||||
use super::response::{decode_json_response, ensure_success};
|
||||
|
||||
pub const MAX_TXN_OPERATIONS: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ConsulTxnVerb {
|
||||
Get,
|
||||
GetTree,
|
||||
Set,
|
||||
Cas,
|
||||
Delete,
|
||||
DeleteCas,
|
||||
DeleteTree,
|
||||
Lock,
|
||||
Unlock,
|
||||
CheckIndex,
|
||||
CheckNotExists,
|
||||
CheckSession,
|
||||
}
|
||||
|
||||
impl ConsulTxnVerb {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Get => "get",
|
||||
Self::GetTree => "get-tree",
|
||||
Self::Set => "set",
|
||||
Self::Cas => "cas",
|
||||
Self::Delete => "delete",
|
||||
Self::DeleteCas => "delete-cas",
|
||||
Self::DeleteTree => "delete-tree",
|
||||
Self::Lock => "lock",
|
||||
Self::Unlock => "unlock",
|
||||
Self::CheckIndex => "check-index",
|
||||
Self::CheckNotExists => "check-not-exists",
|
||||
Self::CheckSession => "check-session",
|
||||
}
|
||||
}
|
||||
|
||||
fn writes(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Set | Self::Cas | Self::Delete | Self::DeleteCas | Self::DeleteTree | Self::Lock | Self::Unlock
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulTxnKvOperation {
|
||||
pub verb: ConsulTxnVerb,
|
||||
pub key: String,
|
||||
pub value: Option<KvValue>,
|
||||
pub flags: Option<KvInt64>,
|
||||
pub index: Option<KvInt64>,
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulTxnRequest {
|
||||
pub operations: Vec<ConsulTxnKvOperation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulTxnError {
|
||||
pub op_index: usize,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulTxnResult {
|
||||
pub committed: bool,
|
||||
pub errors: Vec<ConsulTxnError>,
|
||||
pub results: Vec<ConsulTxnKvResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsulTxnKvResult {
|
||||
pub key: String,
|
||||
pub value: Option<KvValue>,
|
||||
pub flags: KvInt64,
|
||||
pub create_index: KvInt64,
|
||||
pub modify_index: KvInt64,
|
||||
pub lock_index: KvInt64,
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct RawTxnResponse {
|
||||
#[serde(default)]
|
||||
errors: Option<Vec<RawTxnError>>,
|
||||
#[serde(default)]
|
||||
results: Option<Vec<RawTxnResult>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct RawTxnResult {
|
||||
#[serde(rename = "KV", alias = "Kv")]
|
||||
kv: Option<RawTxnKvResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct RawTxnKvResult {
|
||||
#[serde(default)]
|
||||
key: String,
|
||||
value: Option<String>,
|
||||
#[serde(default)]
|
||||
flags: u64,
|
||||
#[serde(default)]
|
||||
create_index: u64,
|
||||
#[serde(default)]
|
||||
modify_index: u64,
|
||||
#[serde(default)]
|
||||
lock_index: u64,
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct RawTxnError {
|
||||
op_index: usize,
|
||||
what: String,
|
||||
}
|
||||
|
||||
impl ConsulClient {
|
||||
pub(super) async fn txn(&self, request: ConsulTxnRequest) -> Result<ConsulTxnResult, String> {
|
||||
validate_operations(&request.operations)?;
|
||||
let body = serde_json::to_vec(&request.operations.iter().map(operation_json).collect::<Result<Vec<_>, _>>()?)
|
||||
.map_err(|error| format!("Failed to encode Consul transaction: {error}"))?;
|
||||
let mut url = self.api_url("/v1/txn")?;
|
||||
self.append_scope(&mut url, false);
|
||||
let response = self.send(Method::PUT, url, Some(body)).await?;
|
||||
let status = response.status();
|
||||
let response = if is_txn_result_status(response.status()) {
|
||||
response
|
||||
} else {
|
||||
ensure_success(response, "execute transaction", self.token()).await?
|
||||
};
|
||||
let response = decode_json_response::<RawTxnResponse>(response, "execute transaction").await?;
|
||||
map_txn_response(status, response)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_txn_response(status: StatusCode, response: RawTxnResponse) -> Result<ConsulTxnResult, String> {
|
||||
let errors = response
|
||||
.errors
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|error| ConsulTxnError { op_index: error.op_index, message: error.what })
|
||||
.collect::<Vec<_>>();
|
||||
let results = response
|
||||
.results
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|result| result.kv)
|
||||
.map(txn_result)
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(ConsulTxnResult { committed: status.is_success() && errors.is_empty(), errors, results })
|
||||
}
|
||||
|
||||
fn is_txn_result_status(status: StatusCode) -> bool {
|
||||
status.is_success() || status == StatusCode::CONFLICT
|
||||
}
|
||||
|
||||
pub async fn consul_txn_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
request: ConsulTxnRequest,
|
||||
) -> Result<ConsulTxnResult, String> {
|
||||
validate_public_operations(&request.operations)?;
|
||||
if request.operations.iter().any(|operation| operation.verb.writes()) {
|
||||
ensure_writable_core(state, connection_id, "execute Consul transaction").await?;
|
||||
}
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
for operation in request
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| matches!(operation.verb, ConsulTxnVerb::Cas | ConsulTxnVerb::DeleteCas))
|
||||
{
|
||||
let current = client.get_key(&operation.key).await?;
|
||||
if current
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.session.as_deref())
|
||||
.is_some_and(|session| !session.is_empty())
|
||||
{
|
||||
return Err(format!("CONSUL_KEY_LOCKED: The Consul KV key '{}' is held by a Session", operation.key));
|
||||
}
|
||||
}
|
||||
client.txn(request).await
|
||||
}
|
||||
|
||||
pub async fn consul_rename_key_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
expected_modify_index: KvInt64,
|
||||
copy: bool,
|
||||
) -> Result<ConsulTxnResult, String> {
|
||||
ensure_writable_core(state, connection_id, if copy { "copy Consul KV key" } else { "rename Consul KV key" })
|
||||
.await?;
|
||||
if source.is_empty() || target.is_empty() || source == target {
|
||||
return Err("CONSUL_RENAME_INVALID: Source and target keys must be distinct and non-empty".to_string());
|
||||
}
|
||||
let expected = parse_index(&expected_modify_index)?;
|
||||
let client = client_for_state(state, connection_id).await?;
|
||||
let current = client.get_key(source).await?;
|
||||
if !current.found {
|
||||
return Err("CONSUL_KEY_NOT_FOUND: Source key no longer exists".to_string());
|
||||
}
|
||||
let current_index = current
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.mod_revision.as_ref())
|
||||
.ok_or("CONSUL_CAS_REQUIRED: Source key has no ModifyIndex")?;
|
||||
if parse_index(current_index)? != expected {
|
||||
return Err("CONSUL_CAS_CONFLICT: Source key changed before the transaction".to_string());
|
||||
}
|
||||
if current.metadata.as_ref().and_then(|metadata| metadata.session.as_deref()).is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Err("CONSUL_KEY_LOCKED: Source key is held by a Consul Session".to_string());
|
||||
}
|
||||
let mut operations = vec![
|
||||
ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb::CheckIndex,
|
||||
key: source.to_string(),
|
||||
value: None,
|
||||
flags: None,
|
||||
index: Some(expected_modify_index.clone()),
|
||||
session: None,
|
||||
},
|
||||
operation(ConsulTxnVerb::CheckNotExists, target),
|
||||
ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb::Cas,
|
||||
key: target.to_string(),
|
||||
value: current.value,
|
||||
flags: current.metadata.as_ref().and_then(|metadata| metadata.flags.clone()),
|
||||
index: Some(KvInt64("0".to_string())),
|
||||
session: None,
|
||||
},
|
||||
];
|
||||
if !copy {
|
||||
operations.push(ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb::DeleteCas,
|
||||
key: source.to_string(),
|
||||
value: None,
|
||||
flags: None,
|
||||
index: Some(expected_modify_index),
|
||||
session: None,
|
||||
});
|
||||
}
|
||||
client.txn(ConsulTxnRequest { operations }).await
|
||||
}
|
||||
|
||||
pub(crate) fn cas_operation(key: String, value: KvValue, flags: KvInt64, index: KvInt64) -> ConsulTxnKvOperation {
|
||||
ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb::Cas,
|
||||
key,
|
||||
value: Some(value),
|
||||
flags: Some(flags),
|
||||
index: Some(index),
|
||||
session: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_cas_operation(key: String, index: KvInt64) -> ConsulTxnKvOperation {
|
||||
ConsulTxnKvOperation {
|
||||
verb: ConsulTxnVerb::DeleteCas,
|
||||
key,
|
||||
value: None,
|
||||
flags: None,
|
||||
index: Some(index),
|
||||
session: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn operation(verb: ConsulTxnVerb, key: &str) -> ConsulTxnKvOperation {
|
||||
ConsulTxnKvOperation { verb, key: key.to_string(), value: None, flags: None, index: None, session: None }
|
||||
}
|
||||
|
||||
fn validate_operations(operations: &[ConsulTxnKvOperation]) -> Result<(), String> {
|
||||
if operations.is_empty() {
|
||||
return Err("CONSUL_TXN_EMPTY: A transaction requires at least one operation".to_string());
|
||||
}
|
||||
if operations.len() > MAX_TXN_OPERATIONS {
|
||||
return Err(format!(
|
||||
"CONSUL_TXN_LIMIT_EXCEEDED: Consul transactions support at most {MAX_TXN_OPERATIONS} operations"
|
||||
));
|
||||
}
|
||||
for operation in operations {
|
||||
if operation.key.is_empty() {
|
||||
return Err("CONSUL_TXN_KEY_REQUIRED: Transaction keys cannot be empty".to_string());
|
||||
}
|
||||
if operation.index.as_ref().is_some_and(|index| parse_index(index).is_err()) {
|
||||
return Err(format!("CONSUL_TXN_INDEX_INVALID: Invalid Index for key {}", operation.key));
|
||||
}
|
||||
if operation.flags.as_ref().is_some_and(|flags| parse_index(flags).is_err()) {
|
||||
return Err(format!("CONSUL_TXN_FLAGS_INVALID: Invalid Flags for key {}", operation.key));
|
||||
}
|
||||
if matches!(operation.verb, ConsulTxnVerb::Lock | ConsulTxnVerb::Unlock | ConsulTxnVerb::CheckSession)
|
||||
&& operation.session.as_deref().map(str::trim).is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(format!(
|
||||
"CONSUL_TXN_SESSION_REQUIRED: {:?} requires a Session for key {}",
|
||||
operation.verb, operation.key
|
||||
));
|
||||
}
|
||||
if matches!(operation.verb, ConsulTxnVerb::Lock) && operation.value.is_none() {
|
||||
return Err(format!("CONSUL_TXN_VALUE_REQUIRED: lock requires a value for key {}", operation.key));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_public_operations(operations: &[ConsulTxnKvOperation]) -> Result<(), String> {
|
||||
validate_operations(operations)?;
|
||||
for operation in operations {
|
||||
match operation.verb {
|
||||
ConsulTxnVerb::Set | ConsulTxnVerb::Delete | ConsulTxnVerb::DeleteTree => {
|
||||
return Err(format!(
|
||||
"CONSUL_TXN_UNSAFE_VERB: Public transactions cannot use unconditional {:?}; use CAS or a dedicated prefix workflow",
|
||||
operation.verb
|
||||
));
|
||||
}
|
||||
ConsulTxnVerb::Cas | ConsulTxnVerb::DeleteCas if operation.index.is_none() => {
|
||||
return Err(format!(
|
||||
"CONSUL_CAS_REQUIRED: {:?} requires an Index for key {}",
|
||||
operation.verb, operation.key
|
||||
));
|
||||
}
|
||||
ConsulTxnVerb::Cas if operation.value.is_none() => {
|
||||
return Err(format!("CONSUL_TXN_VALUE_REQUIRED: cas requires a value for key {}", operation.key));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn txn_result(raw: RawTxnKvResult) -> Result<ConsulTxnKvResult, String> {
|
||||
let value = raw
|
||||
.value
|
||||
.as_deref()
|
||||
.map(|encoded| {
|
||||
let bytes = STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|error| format!("CONSUL_INVALID_RESPONSE: Invalid transaction Value: {error}"))?;
|
||||
Ok::<_, String>(match String::from_utf8(bytes.clone()) {
|
||||
Ok(value) => KvValue { encoding: KvValueEncoding::Utf8, data: value },
|
||||
Err(_) => KvValue { encoding: KvValueEncoding::Base64, data: STANDARD.encode(bytes) },
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(ConsulTxnKvResult {
|
||||
key: raw.key,
|
||||
value,
|
||||
flags: KvInt64(raw.flags.to_string()),
|
||||
create_index: KvInt64(raw.create_index.to_string()),
|
||||
modify_index: KvInt64(raw.modify_index.to_string()),
|
||||
lock_index: KvInt64(raw.lock_index.to_string()),
|
||||
session: raw.session.filter(|session| !session.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
fn operation_json(operation: &ConsulTxnKvOperation) -> Result<serde_json::Value, String> {
|
||||
let mut kv = serde_json::Map::new();
|
||||
kv.insert("Verb".to_string(), operation.verb.as_str().into());
|
||||
kv.insert("Key".to_string(), operation.key.clone().into());
|
||||
if let Some(value) = &operation.value {
|
||||
let bytes = match value.encoding {
|
||||
KvValueEncoding::Utf8 => value.data.as_bytes().to_vec(),
|
||||
KvValueEncoding::Base64 => STANDARD
|
||||
.decode(value.data.chars().filter(|character| !character.is_whitespace()).collect::<String>())
|
||||
.map_err(|error| format!("CONSUL_VALUE_ENCODING_INVALID: {error}"))?,
|
||||
};
|
||||
kv.insert("Value".to_string(), STANDARD.encode(bytes).into());
|
||||
}
|
||||
if let Some(flags) = &operation.flags {
|
||||
kv.insert("Flags".to_string(), parse_index(flags)?.into());
|
||||
}
|
||||
if let Some(index) = &operation.index {
|
||||
kv.insert("Index".to_string(), parse_index(index)?.into());
|
||||
}
|
||||
if let Some(session) = &operation.session {
|
||||
kv.insert("Session".to_string(), session.clone().into());
|
||||
}
|
||||
Ok(serde_json::json!({ "KV": kv }))
|
||||
}
|
||||
|
||||
fn parse_index(value: &KvInt64) -> Result<u64, String> {
|
||||
value.as_str().parse::<u64>().map_err(|_| format!("Invalid unsigned 64-bit integer: {}", value.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::consul::test_support::{serve_once, test_client};
|
||||
|
||||
#[test]
|
||||
fn enforces_transaction_limit() {
|
||||
let operations =
|
||||
(0..65).map(|index| operation(ConsulTxnVerb::Get, &format!("key-{index}"))).collect::<Vec<_>>();
|
||||
assert!(validate_operations(&operations).unwrap_err().contains("CONSUL_TXN_LIMIT_EXCEEDED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_u64_as_json_number_and_value_as_base64() {
|
||||
let value = KvValue { encoding: KvValueEncoding::Utf8, data: "hello".to_string() };
|
||||
let encoded = operation_json(&cas_operation(
|
||||
"a b".to_string(),
|
||||
value,
|
||||
KvInt64(u64::MAX.to_string()),
|
||||
KvInt64("0".to_string()),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(encoded["KV"]["Value"], "aGVsbG8=");
|
||||
assert_eq!(encoded["KV"]["Flags"], u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_transactions_reject_unconditional_writes_and_require_cas_fields() {
|
||||
for verb in [ConsulTxnVerb::Set, ConsulTxnVerb::Delete, ConsulTxnVerb::DeleteTree] {
|
||||
assert!(validate_public_operations(&[operation(verb, "key")]).unwrap_err().contains("UNSAFE_VERB"));
|
||||
}
|
||||
assert!(validate_public_operations(&[operation(ConsulTxnVerb::Cas, "key")])
|
||||
.unwrap_err()
|
||||
.contains("CAS_REQUIRED"));
|
||||
let mut cas = cas_operation(
|
||||
"key".into(),
|
||||
KvValue { encoding: KvValueEncoding::Utf8, data: "value".into() },
|
||||
KvInt64("0".into()),
|
||||
KvInt64("0".into()),
|
||||
);
|
||||
assert!(validate_public_operations(&[cas.clone()]).is_ok());
|
||||
cas.value = None;
|
||||
assert!(validate_public_operations(&[cas]).unwrap_err().contains("VALUE_REQUIRED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_results_preserve_kv_data_and_indexes() {
|
||||
let result = txn_result(RawTxnKvResult {
|
||||
key: "binary".into(),
|
||||
value: Some("AP+A".into()),
|
||||
flags: u64::MAX,
|
||||
create_index: 2,
|
||||
modify_index: 3,
|
||||
lock_index: 4,
|
||||
session: Some("session-1".into()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.value.unwrap().encoding, KvValueEncoding::Base64);
|
||||
assert_eq!(result.flags.as_str(), u64::MAX.to_string());
|
||||
assert_eq!(result.modify_index.as_str(), "3");
|
||||
assert_eq!(result.session.as_deref(), Some("session-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_conflicts_are_decoded_as_results() {
|
||||
assert!(is_txn_result_status(StatusCode::OK));
|
||||
assert!(is_txn_result_status(StatusCode::CONFLICT));
|
||||
assert!(!is_txn_result_status(StatusCode::BAD_REQUEST));
|
||||
assert!(!is_txn_result_status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_conflict_response_is_never_committed() {
|
||||
let result =
|
||||
map_txn_response(StatusCode::CONFLICT, RawTxnResponse { errors: Some(vec![]), results: Some(vec![]) })
|
||||
.unwrap();
|
||||
assert!(!result.committed);
|
||||
assert!(result.errors.is_empty());
|
||||
assert!(result.results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_delete_response_accepts_consul_null_collections() {
|
||||
// Consul returns this exact shape for a successful delete-cas transaction.
|
||||
let raw: RawTxnResponse = serde_json::from_str(r#"{"Results":[],"Errors":null}"#).unwrap();
|
||||
let result = map_txn_response(StatusCode::OK, raw).unwrap();
|
||||
assert!(result.committed);
|
||||
assert!(result.errors.is_empty());
|
||||
assert!(result.results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflict_response_preserves_results_errors_and_wire_contract() {
|
||||
let body = r#"{"Results":[{"KV":{"Key":"source","Value":"aGVsbG8=","Flags":7,"CreateIndex":2,"ModifyIndex":3,"LockIndex":0}}],"Errors":[{"OpIndex":1,"What":"target exists"}]}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let (url, request_rx) = serve_once(response).await;
|
||||
let client = test_client(url).await;
|
||||
let result =
|
||||
client.txn(ConsulTxnRequest { operations: vec![operation(ConsulTxnVerb::Get, "source")] }).await.unwrap();
|
||||
|
||||
assert!(!result.committed);
|
||||
assert_eq!(result.errors[0].op_index, 1);
|
||||
assert_eq!(result.results[0].key, "source");
|
||||
assert_eq!(result.results[0].value.as_ref().unwrap().data, "hello");
|
||||
|
||||
let request = request_rx.await.unwrap();
|
||||
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
assert!(headers.starts_with("PUT /proxy/v1/txn?dc=dc1&ns=team-a&partition=partition-a HTTP/1.1"));
|
||||
assert!(headers.to_ascii_lowercase().contains("x-consul-token: fixture-token"));
|
||||
let wire: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(wire[0]["KV"]["Verb"], "get");
|
||||
assert_eq!(wire[0]["KV"]["Key"], "source");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub(super) struct ConsulKvEntry {
|
||||
pub(super) create_index: u64,
|
||||
pub(super) modify_index: u64,
|
||||
pub(super) lock_index: u64,
|
||||
pub(super) key: String,
|
||||
pub(super) flags: u64,
|
||||
pub(super) value: Option<String>,
|
||||
pub(super) session: Option<String>,
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod cloud_sync;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod connection_secrets;
|
||||
pub mod consul;
|
||||
pub mod correction;
|
||||
pub mod csv_export;
|
||||
pub mod data_compare;
|
||||
|
||||
@@ -2,6 +2,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -68,7 +69,7 @@ pub fn database_info_from_protocol_value(value: &Value) -> Option<DatabaseConnec
|
||||
serde_json::from_value::<DatabaseInfoEnvelope>(value.clone()).ok()?.database_info
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[derive(Clone, Serialize, PartialEq)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -182,6 +183,42 @@ pub struct ConnectionConfig {
|
||||
pub database_info: Option<DatabaseConnectionInfo>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ConnectionConfig {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut value = serde_json::to_value(self).map_err(|_| fmt::Error)?;
|
||||
redact_connection_debug_value(&mut value);
|
||||
formatter.debug_tuple("ConnectionConfig").field(&value).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_connection_debug_value(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
let key = key.to_ascii_lowercase().replace(['_', '-'], "");
|
||||
if key.contains("password")
|
||||
|| key.contains("passphrase")
|
||||
|| key.contains("token")
|
||||
|| key.contains("secret")
|
||||
|| key.contains("apikey")
|
||||
|| key == "connectionstring"
|
||||
|| key == "initscript"
|
||||
{
|
||||
*value = serde_json::Value::String("[REDACTED]".to_string());
|
||||
} else {
|
||||
redact_connection_debug_value(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
redact_connection_debug_value(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum TransportLayerConfig {
|
||||
@@ -536,6 +573,7 @@ pub enum DatabaseType {
|
||||
#[serde(rename = "zookeeper")]
|
||||
ZooKeeper,
|
||||
Nacos,
|
||||
Consul,
|
||||
#[serde(rename = "iris")]
|
||||
Iris,
|
||||
#[serde(rename = "turso")]
|
||||
@@ -1123,6 +1161,7 @@ impl ConnectionConfig {
|
||||
DatabaseType::MessageQueue => self.message_queue_admin_url(),
|
||||
DatabaseType::Mqtt => self.mqtt_broker_url(),
|
||||
DatabaseType::Nacos => self.nacos_admin_url(),
|
||||
DatabaseType::Consul => self.consul_api_url(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,6 +1404,7 @@ impl ConnectionConfig {
|
||||
DatabaseType::MessageQueue => self.message_queue_admin_url(),
|
||||
DatabaseType::Mqtt => self.mqtt_broker_url(),
|
||||
DatabaseType::Nacos => self.nacos_admin_url(),
|
||||
DatabaseType::Consul => self.consul_api_url(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,6 +1441,20 @@ impl ConnectionConfig {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn consul_api_url(&self) -> String {
|
||||
self.external_config
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("serverAddr").or_else(|| value.get("server_addr")))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
let scheme = if self.ssl { "https" } else { "http" };
|
||||
format!("{scheme}://{}:{}", bracket_ipv6(&self.host), self.port)
|
||||
})
|
||||
}
|
||||
|
||||
fn mqtt_broker_url(&self) -> String {
|
||||
#[cfg(feature = "mq-admin")]
|
||||
{
|
||||
@@ -2368,6 +2422,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_debug_redacts_passwords_tokens_and_nested_secrets() {
|
||||
let mut config = mysql_config("root", "database-password", Some("app"));
|
||||
config.connection_string = Some("server=db;password=inline-secret".into());
|
||||
config.init_script = Some("CREATE SECRET leaked".into());
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"consulToken": "consul-secret",
|
||||
"nested": { "OIDCClientSecret": "oidc-secret", "visible": "safe" }
|
||||
}));
|
||||
|
||||
let output = format!("{config:?}");
|
||||
for secret in ["database-password", "inline-secret", "CREATE SECRET leaked", "consul-secret", "oidc-secret"] {
|
||||
assert!(!output.contains(secret));
|
||||
}
|
||||
assert!(output.contains("[REDACTED]"));
|
||||
assert!(output.contains("safe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_mcp_access_is_ignored_and_not_serialized() {
|
||||
let legacy: ConnectionConfig = serde_json::from_value(serde_json::json!({
|
||||
|
||||
@@ -1669,6 +1669,7 @@ async fn do_execute_typed(
|
||||
.map(|result| truncate_result_with_max_rows(result, max_rows))
|
||||
}
|
||||
PoolKind::HBase(_) => Err("SQL execution is not supported for HBase connections".to_string()),
|
||||
PoolKind::Consul(_) => Err("SQL execution is not supported for Consul connections".to_string()),
|
||||
};
|
||||
result
|
||||
.map(normalize_query_result_for_js)
|
||||
@@ -2852,6 +2853,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
|
||||
| PoolKind::Agent(_) => true,
|
||||
PoolKind::MessageQueue
|
||||
| PoolKind::Nacos
|
||||
| PoolKind::Consul(_)
|
||||
| PoolKind::HBase(_)
|
||||
| PoolKind::DuckDbWorker(_)
|
||||
| PoolKind::Redis(_)
|
||||
@@ -3095,7 +3097,7 @@ pub async fn execute_statements_in_transaction_on_pool_typed(
|
||||
TxPath::Explicit
|
||||
}
|
||||
PoolKind::Agent(client) => TxPath::Agent(client.clone()),
|
||||
PoolKind::MessageQueue | PoolKind::Nacos | PoolKind::HBase(_) => TxPath::None,
|
||||
PoolKind::MessageQueue | PoolKind::Nacos | PoolKind::Consul(_) | PoolKind::HBase(_) => TxPath::None,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
PoolKind::Mqtt(_) => TxPath::None,
|
||||
PoolKind::DuckDbWorker(_)
|
||||
|
||||
@@ -674,7 +674,7 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
|
||||
|
||||
// Non-tabular / not applicable for relational CREATE TABLE
|
||||
Redis | MongoDb | Elasticsearch | Easysearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra
|
||||
| Etcd | ZooKeeper | Nacos | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
|
||||
| Etcd | ZooKeeper | Nacos | Consul | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
|
||||
conservative_ansi(db_type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2627,7 +2627,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let export = run_external_driver_export(&fixture);
|
||||
let export = Box::pin(run_external_driver_export(&fixture));
|
||||
let cancel = async {
|
||||
wait_for_external_driver_call(&fixture.calls, "executeQueryPage").await;
|
||||
set_export_cancelled(&fixture.request.export_id).await;
|
||||
@@ -2666,7 +2666,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let export = run_external_driver_export(&fixture);
|
||||
let export = Box::pin(run_external_driver_export(&fixture));
|
||||
let cancel = async {
|
||||
wait_for_external_driver_call(&fixture.calls, "fetchQueryPage").await;
|
||||
set_export_cancelled(&fixture.request.export_id).await;
|
||||
|
||||
@@ -557,6 +557,109 @@ async fn main() {
|
||||
.route("/zookeeper/get", post(routes::zookeeper::get))
|
||||
.route("/zookeeper/put", post(routes::zookeeper::put))
|
||||
.route("/zookeeper/delete", post(routes::zookeeper::delete))
|
||||
// Consul
|
||||
.route("/consul/capabilities", post(routes::consul::capabilities))
|
||||
.route("/consul/txn", post(routes::consul::txn))
|
||||
.route("/consul/rename-key", post(routes::consul::rename_key))
|
||||
.route("/consul/blocking-query", post(routes::consul::blocking_query))
|
||||
.route("/consul/domain-watch", post(routes::consul::domain_watch))
|
||||
.route("/consul/cancel-blocking", post(routes::consul::cancel_blocking))
|
||||
.route("/consul/list-prefix", post(routes::consul::list_prefix))
|
||||
.route("/consul/list-recursive", post(routes::consul::list_recursive))
|
||||
.route("/consul/search", post(routes::consul::search))
|
||||
.route("/consul/search-progress", post(routes::consul::search_progress))
|
||||
.route("/consul/cancel-search", post(routes::consul::cancel_search))
|
||||
.route("/consul/export-bundle", post(routes::consul::export_bundle))
|
||||
.route("/consul/import-preview", post(routes::consul::import_preview))
|
||||
.route("/consul/import-execute", post(routes::consul::import_execute))
|
||||
.route("/consul/delete-prefix-preview", post(routes::consul::delete_prefix_preview))
|
||||
.route("/consul/delete-prefix-execute", post(routes::consul::delete_prefix_execute))
|
||||
.route("/consul/get", post(routes::consul::get))
|
||||
.route("/consul/put", post(routes::consul::put))
|
||||
.route("/consul/delete", post(routes::consul::delete))
|
||||
.route("/consul/prepared-query/list", post(routes::consul::prepared_query_list))
|
||||
.route("/consul/prepared-query/read", post(routes::consul::prepared_query_read))
|
||||
.route("/consul/prepared-query/create", post(routes::consul::prepared_query_create))
|
||||
.route("/consul/prepared-query/update", post(routes::consul::prepared_query_update))
|
||||
.route("/consul/prepared-query/delete", post(routes::consul::prepared_query_delete))
|
||||
.route("/consul/prepared-query/execute", post(routes::consul::prepared_query_execute))
|
||||
.route("/consul/prepared-query/explain", post(routes::consul::prepared_query_explain))
|
||||
.route("/consul/event/list", post(routes::consul::event_list))
|
||||
.route("/consul/event/fire", post(routes::consul::event_fire).layer(DefaultBodyLimit::max(16 * 1024)))
|
||||
.route("/consul/coordinate/nodes", post(routes::consul::coordinate_nodes))
|
||||
.route("/consul/operator/read", post(routes::consul::operator_read))
|
||||
.route("/consul/operator/snapshot/generate", post(routes::consul::snapshot_generate))
|
||||
.route("/consul/operator/snapshot/restore", post(routes::consul::snapshot_restore))
|
||||
.route("/consul/operator/autopilot/update", post(routes::consul::autopilot_update))
|
||||
.route("/consul/operator/raft/transfer", post(routes::consul::raft_transfer))
|
||||
.route("/consul/operator/raft/remove", post(routes::consul::raft_remove))
|
||||
.route("/consul/operator/keyring/write", post(routes::consul::keyring_write))
|
||||
.route("/consul/operator/license/write", post(routes::consul::license_write))
|
||||
.route("/consul/status/leader", post(routes::consul::status_leader))
|
||||
.route("/consul/status/peers", post(routes::consul::status_peers))
|
||||
.route("/consul/agent/self", post(routes::consul::agent_self))
|
||||
.route("/consul/agent/members", post(routes::consul::agent_members))
|
||||
.route("/consul/agent/metrics", post(routes::consul::agent_metrics))
|
||||
.route("/consul/catalog/datacenters", post(routes::consul::catalog_datacenters))
|
||||
.route("/consul/catalog/nodes", post(routes::consul::catalog_nodes))
|
||||
.route("/consul/catalog/services", post(routes::consul::catalog_services))
|
||||
.route("/consul/catalog/service-nodes", post(routes::consul::catalog_service_nodes))
|
||||
.route("/consul/catalog/node-services", post(routes::consul::catalog_node_services))
|
||||
.route("/consul/health/node", post(routes::consul::health_node))
|
||||
.route("/consul/health/checks", post(routes::consul::health_checks))
|
||||
.route("/consul/health/service", post(routes::consul::health_service))
|
||||
.route("/consul/health/state", post(routes::consul::health_state))
|
||||
.route("/consul/agent/services", post(routes::consul::agent_services))
|
||||
.route("/consul/agent/service", post(routes::consul::agent_service))
|
||||
.route("/consul/agent/checks", post(routes::consul::agent_checks))
|
||||
.route("/consul/agent/service/register", post(routes::consul::agent_register_service))
|
||||
.route("/consul/agent/service/deregister", post(routes::consul::agent_deregister_service))
|
||||
.route("/consul/agent/service/maintenance", post(routes::consul::agent_service_maintenance))
|
||||
.route("/consul/agent/check/register", post(routes::consul::agent_register_check))
|
||||
.route("/consul/agent/check/deregister", post(routes::consul::agent_deregister_check))
|
||||
.route("/consul/agent/check/ttl", post(routes::consul::agent_update_ttl))
|
||||
.route("/consul/sessions", post(routes::consul::sessions))
|
||||
.route("/consul/sessions/node", post(routes::consul::node_sessions))
|
||||
.route("/consul/session", post(routes::consul::session))
|
||||
.route("/consul/session/keys", post(routes::consul::session_keys))
|
||||
.route("/consul/session/destroy-impact", post(routes::consul::session_destroy_impact))
|
||||
.route("/consul/session/create", post(routes::consul::create_session))
|
||||
.route("/consul/session/renew", post(routes::consul::renew_session))
|
||||
.route("/consul/session/destroy", post(routes::consul::destroy_session))
|
||||
.route("/consul/lock/acquire", post(routes::consul::acquire_lock))
|
||||
.route("/consul/lock/release", post(routes::consul::release_lock))
|
||||
.route("/consul/acl/list", post(routes::consul::acl_list))
|
||||
.route("/consul/acl/token/self", post(routes::consul::acl_token_self))
|
||||
.route("/consul/acl/token/clone", post(routes::consul::acl_token_clone))
|
||||
.route("/consul/acl/get", post(routes::consul::acl_get))
|
||||
.route("/consul/acl/apply", post(routes::consul::acl_apply))
|
||||
.route("/consul/acl/references", post(routes::consul::acl_references))
|
||||
.route("/consul/acl/delete", post(routes::consul::acl_delete))
|
||||
.route("/consul/enterprise/list", post(routes::consul::enterprise_list))
|
||||
.route("/consul/enterprise/get", post(routes::consul::enterprise_get))
|
||||
.route("/consul/enterprise/apply", post(routes::consul::enterprise_apply))
|
||||
.route("/consul/enterprise/impact", post(routes::consul::enterprise_impact))
|
||||
.route("/consul/enterprise/delete", post(routes::consul::enterprise_delete))
|
||||
.route("/consul/mesh/config/list", post(routes::consul::mesh_config_list))
|
||||
.route("/consul/mesh/config/get", post(routes::consul::mesh_config_get))
|
||||
.route("/consul/mesh/config/apply", post(routes::consul::mesh_config_apply))
|
||||
.route("/consul/mesh/config/delete", post(routes::consul::mesh_config_delete))
|
||||
.route("/consul/mesh/intentions/list", post(routes::consul::mesh_intentions_list))
|
||||
.route("/consul/mesh/intentions/get", post(routes::consul::mesh_intention_get))
|
||||
.route("/consul/mesh/intentions/get-exact", post(routes::consul::mesh_intention_get_exact))
|
||||
.route("/consul/mesh/intentions/upsert", post(routes::consul::mesh_intention_upsert))
|
||||
.route("/consul/mesh/intentions/delete", post(routes::consul::mesh_intention_delete))
|
||||
.route("/consul/mesh/intentions/delete-exact", post(routes::consul::mesh_intention_delete_exact))
|
||||
.route("/consul/mesh/intentions/match", post(routes::consul::mesh_intention_match))
|
||||
.route("/consul/mesh/intentions/check", post(routes::consul::mesh_intention_check))
|
||||
.route("/consul/mesh/discovery-chain", post(routes::consul::mesh_discovery_chain))
|
||||
.route("/consul/mesh/peerings/list", post(routes::consul::mesh_peering_list))
|
||||
.route("/consul/mesh/peerings/get", post(routes::consul::mesh_peering_get))
|
||||
.route("/consul/mesh/peerings/generate-token", post(routes::consul::mesh_peering_generate_token))
|
||||
.route("/consul/mesh/peerings/establish", post(routes::consul::mesh_peering_establish))
|
||||
.route("/consul/mesh/peerings/delete", post(routes::consul::mesh_peering_delete))
|
||||
.route("/consul/mesh/exported-services/list", post(routes::consul::mesh_exported_services_list))
|
||||
.route("/consul/mesh/exported-services/apply", post(routes::consul::mesh_exported_services_apply))
|
||||
// HBase REST
|
||||
.route("/hbase/table-schema", post(routes::hbase::get_table_schema))
|
||||
.route("/hbase/scan-rows", post(routes::hbase::scan_rows))
|
||||
|
||||
@@ -180,6 +180,41 @@ async fn run_temporary_connection_test(
|
||||
None
|
||||
};
|
||||
|
||||
// Keep all fallible post-connect checks inside this block so cleanup below
|
||||
// runs before either a successful result or an error is returned.
|
||||
let result: Result<ConnectionTestResult, String> = async {
|
||||
let success_message = if pool_result.is_ok() && config.db_type == DatabaseType::Consul {
|
||||
let client = {
|
||||
let connections = app.connections.read().await;
|
||||
match connections.get(&temp_id) {
|
||||
Some(PoolKind::Consul(client)) => Some(client.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(client) = client {
|
||||
let configured_target =
|
||||
dbx_core::consul::ConsulConfig::from_connection(&config)?.agent_target.is_some();
|
||||
let identity = if configured_target {
|
||||
Some(client.validate_configured_agent_target().await?)
|
||||
} else {
|
||||
client.agent_self().await.ok()
|
||||
};
|
||||
identity
|
||||
.map(|identity| format!("Connection successful (Agent: {} at {})", identity.node, identity.address))
|
||||
.unwrap_or_else(|| {
|
||||
"Connection successful (Agent identity unavailable; Agent writes disabled)".to_string()
|
||||
})
|
||||
} else {
|
||||
"Connection successful (Agent identity unavailable; Agent writes disabled)".to_string()
|
||||
}
|
||||
} else {
|
||||
"Connection successful".to_string()
|
||||
};
|
||||
|
||||
pool_result.map(|_| ConnectionTestResult::success(success_message).with_database_info(database_info))
|
||||
}
|
||||
.await;
|
||||
|
||||
app.remove_connection_pools(&temp_id).await;
|
||||
// Pool drain intentionally keeps durable MQ adapters for reconnect reuse; temporary
|
||||
// probes must still release any registry entry if a cached path was used.
|
||||
@@ -188,7 +223,7 @@ async fn run_temporary_connection_test(
|
||||
app.reset_connection_transport_for_config(&temp_id, &config).await;
|
||||
app.configs.write().await.remove(&temp_id);
|
||||
|
||||
pool_result.map(|_| ConnectionTestResult::success("Connection successful").with_database_info(database_info))
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn test_connection(
|
||||
@@ -484,9 +519,10 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections,
|
||||
mark_mongo_legacy_driver, mcp_add_connection, mcp_remove_connection, save_connection_database_info,
|
||||
save_connections, test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest,
|
||||
McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
|
||||
mark_mongo_legacy_driver, mcp_add_connection, mcp_remove_connection, run_temporary_connection_test,
|
||||
save_connection_database_info, save_connections, test_connection, test_connection_with_info, ConnectRequest,
|
||||
DisconnectRequest, McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest,
|
||||
SaveConnectionsRequest,
|
||||
};
|
||||
use crate::state::WebState;
|
||||
use axum::extract::State;
|
||||
@@ -498,9 +534,7 @@ mod tests {
|
||||
};
|
||||
use dbx_core::storage::{McpGlobalPolicy, Storage};
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
#[cfg(feature = "mq-admin")]
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
|
||||
@@ -575,6 +609,23 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
fn consul_config(id: &str, server_addr: &str) -> ConnectionConfig {
|
||||
let parsed = reqwest::Url::parse(server_addr).unwrap();
|
||||
let mut config = sqlite_config(id, "");
|
||||
config.name = "Consul".to_string();
|
||||
config.db_type = DatabaseType::Consul;
|
||||
config.host = parsed.host_str().unwrap().to_string();
|
||||
config.port = parsed.port().unwrap();
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"serverAddr": server_addr,
|
||||
"agentTarget": {
|
||||
"node": "expected-agent",
|
||||
"address": "127.0.0.1"
|
||||
}
|
||||
}));
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_legacy_marker_updates_only_mongodb_profiles() {
|
||||
let mut mongo = sqlite_config("mongo", "");
|
||||
@@ -675,6 +726,51 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_consul_agent_target_validation_cleans_up_temporary_state() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let server_addr = spawn_consul_agent_server().await;
|
||||
let config = consul_config("consul-test", &server_addr);
|
||||
|
||||
let error = run_temporary_connection_test(&state.app, config, false).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("CONSUL_AGENT_TARGET_MISMATCH"), "unexpected error: {error}");
|
||||
assert!(state.app.configs.read().await.keys().all(|key| !key.starts_with("__test_")));
|
||||
assert!(state.app.connections.read().await.keys().all(|key| !key.starts_with("__test_")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
async fn spawn_consul_agent_server() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0_u8; 2048];
|
||||
let Ok(n) = stream.read(&mut buf).await else {
|
||||
return;
|
||||
};
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let body = if request.starts_with("GET /v1/agent/self ") {
|
||||
r#"{"Config":{"NodeName":"actual-agent","Datacenter":"dc1"},"Member":{"Addr":"127.0.0.1","Tags":{}}}"#
|
||||
} else {
|
||||
"[]"
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn mq_connection_test_does_not_retain_temporary_adapter() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ pub mod ai;
|
||||
pub mod app_settings;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
pub mod consul;
|
||||
pub mod data_compare;
|
||||
pub mod database_export;
|
||||
pub mod dialect;
|
||||
|
||||
Reference in new issue
Block a user