use crate::capabilities::{Capabilities, HelloOutcome};
use crate::connect_options::{ConnectOptions, connect_before};
use crate::error::LaserError;
pub use crate::publish_options::PublishOptions;
use bytes::Bytes;
use dashmap::DashMap;
#[cfg(feature = "streaming")]
use iggy::prelude::*;
use laser_wire::framing::decode_named;
use laser_wire::validate::Validate;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::OnceCell;
use tokio::time::{Duration, sleep};
pub use laser_wire::headers::{
CORRELATION_ID, HEADER_FRAMING_BYTES, HEADER_SOFT_CAP, HEADER_VALUE_MAX,
};
pub const OPS_STREAM_DEFAULT: &str = "_agdx";
type ProducerKey = (String, String);
type ProducerCell = Arc<OnceCell<Arc<IggyProducer>>>;
const TRANSIENT_SEND_ATTEMPTS: usize = 10;
const PUBLISH_BATCH_LENGTH: usize = 1000;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ResourceNaming {
#[default]
Stream,
Bare,
}
#[derive(Clone)]
pub struct Laser {
inner: Arc<LaserInner>,
capability_override: Option<Capabilities>,
ops_stream_override: Option<String>,
control_topic_override: Option<String>,
dlq_topic_override: Option<String>,
changes_topic_override: Option<String>,
stream: Option<String>,
resource_naming: ResourceNaming,
#[cfg(feature = "agent")]
pub(crate) governor: Option<Arc<crate::govern::GovernorState>>,
}
struct LaserInner {
client: std::sync::RwLock<Arc<IggyClient>>,
reconnect_gate: tokio::sync::Mutex<()>,
publish_generation: std::sync::atomic::AtomicU64,
publish_options: PublishOptions,
connection_string: Option<String>,
#[cfg(feature = "kv")]
coordination: OnceCell<Arc<crate::kv::FencedLeaseClient<crate::kv::DedicatedKvTransport>>>,
#[cfg(feature = "kv")]
coordination_acquire_gate: tokio::sync::Mutex<()>,
producers: DashMap<ProducerKey, ProducerCell>,
pub(crate) producer_statistics: Arc<crate::stream::producer_statistics::ProducerRegistry>,
negotiated: std::sync::RwLock<NegotiatedState>,
reprobe_gate: tokio::sync::Mutex<()>,
#[cfg(feature = "agent")]
registry_caches: DashMap<String, Arc<std::sync::Mutex<crate::agent::registry::RegistryCache>>>,
#[cfg(feature = "agent")]
leases: Arc<crate::agent::lease::LeaseRegistry>,
#[cfg(feature = "agent")]
layouts: DashMap<String, crate::agent::SessionLayout>,
#[cfg(all(feature = "agent", any(feature = "query", test)))]
advertised_agent: std::sync::Mutex<Option<crate::types::AgentId>>,
#[cfg(feature = "agent")]
reply_hubs:
DashMap<(String, String), Arc<tokio::sync::OnceCell<crate::agent::replies::ReplyHub>>>,
#[cfg(feature = "sign")]
verifier: Option<Arc<crate::sign::KeyRegistry>>,
}
struct NegotiatedState {
configured_capabilities: Capabilities,
capabilities: Capabilities,
topology: laser_wire::topology::WireTopology,
probed_at: Option<tokio::time::Instant>,
}
const UNMANAGED_REPROBE_INTERVAL: Duration = Duration::from_secs(1);
impl Laser {
#[cfg(all(feature = "agent", feature = "query"))]
pub(crate) fn claim_presence(
&self,
requested: crate::types::AgentId,
) -> Result<(), LaserError> {
claim_presence_slot(&self.inner.advertised_agent, requested)
}
#[cfg(all(feature = "agent", feature = "query"))]
pub(crate) fn release_presence(&self) {
*self
.inner
.advertised_agent
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
#[tracing::instrument(
target = "laser",
level = "info",
skip_all,
fields(operation = "connect")
)]
pub async fn connect(connection_string: &str) -> Result<Self, LaserError> {
LaserBuilder::default()
.connection_string(connection_string)
.build()
.await
}
pub async fn connect_env() -> Result<Self, LaserError> {
let connection = std::env::var("LASER_CONNECTION_STRING")
.map_err(|_| LaserError::Config("LASER_CONNECTION_STRING is not set"))?;
match std::env::var("LASER_STREAM") {
Ok(stream) => Self::connect_with_stream(&connection, &stream).await,
Err(_) => Self::connect(&connection).await,
}
}
pub async fn local() -> Result<Self, LaserError> {
Self::connect("iggy:iggy@127.0.0.1:8090").await
}
pub async fn connect_with_stream(
connection_string: &str,
stream: &str,
) -> Result<Self, LaserError> {
LaserBuilder::default()
.connection_string(connection_string)
.stream(stream)
.build()
.await
}
pub fn builder() -> LaserBuilder {
LaserBuilder::default()
}
pub fn from_client(client: IggyClient) -> Self {
Self {
inner: Arc::new(LaserInner {
client: std::sync::RwLock::new(Arc::new(client)),
publish_options: PublishOptions::default(),
reconnect_gate: tokio::sync::Mutex::new(()),
publish_generation: std::sync::atomic::AtomicU64::new(0),
connection_string: None,
#[cfg(feature = "kv")]
coordination: OnceCell::new(),
#[cfg(feature = "kv")]
coordination_acquire_gate: tokio::sync::Mutex::new(()),
producers: DashMap::new(),
producer_statistics: Default::default(),
negotiated: std::sync::RwLock::new(NegotiatedState {
configured_capabilities: Capabilities::OPEN,
capabilities: Capabilities::OPEN,
topology: laser_wire::topology::WireTopology::default(),
probed_at: None,
}),
reprobe_gate: tokio::sync::Mutex::new(()),
#[cfg(feature = "agent")]
registry_caches: DashMap::new(),
#[cfg(feature = "agent")]
leases: Arc::default(),
#[cfg(feature = "agent")]
layouts: DashMap::new(),
#[cfg(feature = "agent")]
#[cfg(all(feature = "agent", any(feature = "query", test)))]
advertised_agent: std::sync::Mutex::new(None),
#[cfg(feature = "agent")]
reply_hubs: DashMap::new(),
#[cfg(feature = "sign")]
verifier: None,
}),
capability_override: None,
ops_stream_override: None,
control_topic_override: None,
dlq_topic_override: None,
changes_topic_override: None,
stream: None,
resource_naming: ResourceNaming::default(),
#[cfg(feature = "agent")]
governor: None,
}
}
#[cfg(feature = "agent")]
pub(crate) async fn reply_hub(
&self,
reply_topic: &crate::provenance::AgentTopic<'_>,
) -> Result<crate::agent::replies::ReplyHub, LaserError> {
let stream = self.stream_required()?.to_owned();
let topic = reply_topic.topic_string();
let cell = {
self.inner
.reply_hubs
.entry((stream.clone(), topic))
.or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
.clone()
};
let hub = cell
.get_or_try_init(|| {
crate::agent::replies::ReplyHub::create(
self.client(),
stream,
reply_topic.as_identifier(),
#[cfg(feature = "sign")]
self.inner.verifier.clone(),
)
})
.await?;
Ok(hub.clone())
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
self.capability_override = Some(capabilities);
self
}
#[must_use]
pub fn with_ops_stream(mut self, ops_stream: impl Into<String>) -> Self {
self.ops_stream_override = Some(ops_stream.into());
self
}
#[must_use]
pub fn with_control_topic(mut self, control_topic: impl Into<String>) -> Self {
self.control_topic_override = Some(control_topic.into());
self
}
#[must_use]
pub fn with_dlq_topic(mut self, dlq_topic: impl Into<String>) -> Self {
self.dlq_topic_override = Some(dlq_topic.into());
self
}
#[must_use]
pub fn with_changes_topic(mut self, changes_topic: impl Into<String>) -> Self {
self.changes_topic_override = Some(changes_topic.into());
self
}
#[must_use]
pub fn with_default_stream(&self, stream: impl Into<String>) -> Self {
let mut scoped = self.clone();
scoped.stream = Some(stream.into());
scoped
}
#[must_use]
pub fn with_resource_naming(&self, naming: ResourceNaming) -> Self {
let mut renamed = self.clone();
renamed.resource_naming = naming;
renamed
}
pub fn resource_naming(&self) -> ResourceNaming {
self.resource_naming
}
pub fn resource_name(&self, name: &str) -> String {
self.resource_name_in(self.default_stream(), name)
}
pub(crate) fn resource_stream(&self) -> Option<&str> {
self.resource_scope(self.default_stream())
}
pub(crate) fn resource_scope<'a>(&self, stream: Option<&'a str>) -> Option<&'a str> {
match self.resource_naming {
ResourceNaming::Stream => stream.filter(|stream| !stream.is_empty()),
ResourceNaming::Bare => None,
}
}
pub(crate) fn resource_name_in(&self, stream: Option<&str>, name: &str) -> String {
match self.resource_scope(stream) {
Some(stream) if !name.is_empty() => laser_wire::authz::scoped_resource(stream, name),
_ => name.to_owned(),
}
}
#[cfg(any(feature = "kv", feature = "fork", feature = "projections", test))]
pub(crate) fn local_resource_name<'a>(&self, name: &'a str) -> Option<&'a str> {
let Some(stream) = self.resource_stream() else {
return Some(name);
};
match laser_wire::authz::split_scoped_resource(name) {
Some((owner, local)) if owner == stream => Some(local),
_ => None,
}
}
pub(crate) fn publish_options(&self) -> PublishOptions {
self.inner.publish_options
}
pub fn client(&self) -> Arc<IggyClient> {
self.inner
.client
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub async fn close(&self) -> Result<(), LaserError> {
self.inner.producer_statistics.close();
#[cfg(feature = "kv")]
if self.inner.connection_string.is_some() {
self.coordination_client().await?.close().await;
}
#[cfg(feature = "agent")]
self.inner.reply_hubs.clear();
self.client().shutdown().await.map_err(LaserError::from)
}
pub(crate) fn publish_generation(&self) -> u64 {
self.inner
.publish_generation
.load(std::sync::atomic::Ordering::Acquire)
}
pub(crate) async fn reconnect_for_publish(&self, observed: u64) -> Result<(), LaserError> {
let _gate = self.inner.reconnect_gate.lock().await;
if self.publish_generation() != observed {
return Ok(());
}
let client = self.client();
client.disconnect().await?;
client.connect().await?;
self.inner.producers.clear();
self.inner
.publish_generation
.fetch_add(1, std::sync::atomic::Ordering::Release);
Ok(())
}
pub(crate) fn producer_statistics_registry(
&self,
) -> &Arc<crate::stream::producer_statistics::ProducerRegistry> {
&self.inner.producer_statistics
}
pub(crate) fn connection_string(&self) -> Option<&str> {
self.inner.connection_string.as_deref()
}
#[cfg(feature = "kv")]
pub(crate) async fn coordination_client(
&self,
) -> Result<Arc<crate::kv::FencedLeaseClient<crate::kv::DedicatedKvTransport>>, LaserError>
{
let connection = self
.inner
.connection_string
.as_ref()
.ok_or(LaserError::Config(
"fenced lease convenience methods need a connection-backed Laser; use FencedLeaseClient with a dedicated transport for a bring-your-own IggyClient",
))?
.clone();
Ok(self
.inner
.coordination
.get_or_init(|| async move {
Arc::new(crate::kv::FencedLeaseClient::connect_dedicated(connection))
})
.await
.clone())
}
#[cfg(feature = "kv")]
pub(crate) async fn acquire_lease(
&self,
request: laser_wire::kv::KvLease,
) -> Result<crate::kv::Lease, LaserError> {
let laser = self.clone();
tokio::spawn(async move {
let _acquire = laser.inner.coordination_acquire_gate.lock().await;
let coordination = laser.coordination_client().await?;
let prepared = coordination.prepare_acquire(&request)?;
match coordination.acquire(&prepared).await {
Err(error @ LaserError::AmbiguousMutation(_)) => {
sleep(Duration::from_micros(request.lease_ttl_micros)).await;
Err(error)
}
result => result,
}
})
.await
.map_err(|error| {
LaserError::HandlerConfig(format!("coordination acquisition task failed: {error}"))
})?
}
pub fn default_stream(&self) -> Option<&str> {
self.stream.as_deref().filter(|value| !value.is_empty())
}
pub(crate) fn stream_required(&self) -> Result<&str, LaserError> {
self.default_stream().ok_or(LaserError::NoStream)
}
#[cfg(feature = "agent")]
pub(crate) fn declare_layout(&self, stream: &str, layout: crate::agent::SessionLayout) {
self.inner.layouts.insert(stream.to_owned(), layout);
}
#[cfg(feature = "agent")]
pub(crate) fn layout(&self, stream: &str) -> Option<crate::agent::SessionLayout> {
self.inner.layouts.get(stream).map(|layout| layout.clone())
}
#[cfg(feature = "agent")]
pub(crate) fn agent_destination(
&self,
topic: &str,
kind: Option<laser_wire::agent::AgentKind>,
target: Option<&laser_wire::agent::AgentId>,
) -> Option<String> {
let layout = self.default_stream().and_then(|stream| self.layout(stream));
crate::agent::partitioning::destination_topic(layout.as_ref(), topic, kind, target)
}
#[cfg(feature = "agent")]
pub(crate) fn declared_topic(&self, agent: &laser_wire::agent::AgentId) -> Option<String> {
let layout = self.default_stream().and_then(|stream| self.layout(stream));
crate::agent::partitioning::declared_topic(layout.as_ref(), agent)
}
#[cfg(feature = "agent")]
pub(crate) fn lease_registry(&self) -> &Arc<crate::agent::lease::LeaseRegistry> {
&self.inner.leases
}
#[cfg(feature = "agent")]
pub(crate) fn registry_cache(
&self,
) -> Result<Arc<std::sync::Mutex<crate::agent::registry::RegistryCache>>, LaserError> {
let stream = self.stream_required()?;
Ok(self
.inner
.registry_caches
.entry(stream.to_owned())
.or_default()
.clone())
}
#[cfg(feature = "sign")]
pub(crate) fn registry_verifier(&self) -> Option<Arc<crate::sign::KeyRegistry>> {
self.inner.verifier.clone()
}
pub fn ops_stream(&self) -> String {
self.ops_stream_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.ops_stream
.clone()
})
}
pub fn control_topic(&self) -> String {
self.control_topic_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.control_topic
.clone()
})
}
pub fn dlq_topic(&self) -> String {
self.dlq_topic_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.dlq_topic
.clone()
})
}
pub fn changes_topic(&self) -> String {
self.changes_topic_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.changes_topic
.clone()
})
}
pub async fn capabilities(&self) -> Capabilities {
if let Some(capabilities) = &self.capability_override {
return capabilities.clone();
}
if !self.reprobe_due() {
return self.current_capabilities();
}
let _gate = self.inner.reprobe_gate.lock().await;
if !self.reprobe_due() {
return self.current_capabilities();
}
self.refresh_capabilities().await
}
fn reprobe_due(&self) -> bool {
let negotiated = self
.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
!negotiated.capabilities.managed
&& negotiated
.probed_at
.is_none_or(|at| at.elapsed() >= UNMANAGED_REPROBE_INTERVAL)
}
pub(crate) fn current_capabilities(&self) -> Capabilities {
self.capability_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.capabilities
.clone()
})
}
pub async fn refresh_capabilities(&self) -> Capabilities {
#[allow(unused_mut)]
let mut capabilities = self
.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.configured_capabilities
.clone();
#[allow(unused_mut)]
let mut topology = None;
match probe_managed_host(&self.client()).await {
Ok(Some(announce)) => {
merge_announcement(&mut capabilities, &announce);
topology = announce.topology;
capabilities.hello = HelloOutcome::Answered;
}
Ok(None) => capabilities.hello = HelloOutcome::Rejected,
Err(()) => capabilities.hello = HelloOutcome::Failed,
}
let mut negotiated = self
.inner
.negotiated
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
negotiated.capabilities = capabilities;
negotiated.probed_at = Some(tokio::time::Instant::now());
if let Some(topology) = topology {
negotiated.topology = topology;
}
self.capability_override
.clone()
.unwrap_or_else(|| negotiated.capabilities.clone())
}
pub async fn wait_until_ready(&self, timeout: Duration) -> Result<Capabilities, LaserError> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let capabilities = self.refresh_capabilities().await;
if capabilities.is_ready() {
return Ok(capabilities);
}
if capabilities.backends.is_empty() {
return Err(LaserError::unsupported(
"readiness",
"server has no managed backend descriptors",
));
}
if tokio::time::Instant::now() >= deadline {
return Err(LaserError::Timeout("managed backend readiness"));
}
sleep(Duration::from_millis(100)).await;
}
}
pub(crate) async fn ensure_topic_on(
&self,
stream: &str,
topic: &str,
partitions: u32,
) -> Result<(), LaserError> {
let client = self.client();
ensure_stream(&client, stream).await?;
ensure_topic(&client, stream, topic, partitions).await
}
#[cfg(feature = "agent")]
pub(crate) async fn ensure_topic_on_with(
&self,
stream: &str,
topic: &str,
partitions: u32,
expiry: IggyExpiry,
) -> Result<(), LaserError> {
let client = self.client();
ensure_stream(&client, stream).await?;
ensure_topic_with(&client, stream, topic, partitions, expiry).await
}
pub(crate) async fn send_with_headers(
&self,
topic: &str,
payload: impl Into<Vec<u8>>,
headers: BTreeMap<HeaderKey, HeaderValue>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
self.send_with_headers_on(
self.stream_required()?,
topic,
payload,
headers,
partition_key,
)
.await
}
pub(crate) async fn send_with_headers_on(
&self,
stream: &str,
topic: &str,
payload: impl Into<Vec<u8>>,
headers: BTreeMap<HeaderKey, HeaderValue>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
let payload: Vec<u8> = payload.into();
let message = IggyMessage::builder()
.payload(payload.into())
.user_headers(headers)
.build()?;
self.send_batch_on(stream, topic, vec![message], partition_key)
.await
}
pub(crate) async fn send_batch(
&self,
topic: &str,
messages: Vec<IggyMessage>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
let stream = self.stream_required()?.to_owned();
self.send_batch_on(&stream, topic, messages, partition_key)
.await
}
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(topic = %topic, operation = "publish"))]
pub(crate) async fn send_batch_on(
&self,
stream: &str,
topic: &str,
messages: Vec<IggyMessage>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
if messages.is_empty() {
return Ok(SendMessagesResponse {
confirmations: Vec::new(),
});
}
let partitioning = match partition_key {
Some(key) => Partitioning::messages_key_str(key)?,
None => Partitioning::balanced(),
};
self.send_batch_partitioned_on(stream, topic, messages, partitioning)
.await
}
pub(crate) async fn send_batch_partitioned_on(
&self,
stream: &str,
topic: &str,
messages: Vec<IggyMessage>,
partitioning: Partitioning,
) -> Result<SendMessagesResponse, LaserError> {
self.send_batch_partitioned_on_checked(stream, topic, messages, partitioning, || async {
Ok(None)
})
.await
}
pub(crate) async fn send_batch_partitioned_on_checked<F, Fut>(
&self,
stream: &str,
topic: &str,
mut messages: Vec<IggyMessage>,
partitioning: Partitioning,
check: F,
) -> Result<SendMessagesResponse, LaserError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<Option<(u32, u32, Partitioning)>, LaserError>>,
{
if messages.is_empty() {
return Ok(SendMessagesResponse {
confirmations: Vec::new(),
});
}
let first = std::sync::Mutex::new(Some(check().await?));
prepare_publish_messages(&mut messages);
let partitioning = Arc::new(partitioning);
let mut confirmations = Vec::new();
let observed = std::sync::atomic::AtomicU64::new(0);
for (index, chunk) in messages.chunks(PUBLISH_BATCH_LENGTH).enumerate() {
let response = self
.inner
.publish_options
.run(
|| async {
observed.store(
self.publish_generation(),
std::sync::atomic::Ordering::Release,
);
let cached = first.lock().expect("first publish source").take();
let source = match cached {
Some(source) => source,
None => check().await?,
};
if let Some((stream_id, topic_id, route)) = source {
let mut batch: Vec<_> = chunk.iter().map(clone_iggy_message).collect();
return Ok(self
.client()
.send_messages(
&Identifier::numeric(stream_id)?,
&Identifier::numeric(topic_id)?,
&route,
&mut batch,
)
.await?);
}
let producer = self.producer_on(stream, topic).await?;
let sent = producer
.send_with_partitioning(
chunk.iter().map(clone_iggy_message).collect(),
Some(partitioning.clone()),
)
.await;
match sent {
Err(error) if is_missing_resource(&error) => {
self.reconnect_for_publish(self.publish_generation())
.await?;
let producer = self.producer_on(stream, topic).await?;
Ok(producer
.send_with_partitioning(
chunk.iter().map(clone_iggy_message).collect(),
Some(partitioning.clone()),
)
.await?)
}
other => Ok(other?),
}
},
|| {
self.reconnect_for_publish(
observed.load(std::sync::atomic::Ordering::Acquire),
)
},
)
.await
.map_err(|error| {
publish_failure(
error,
&messages[index * PUBLISH_BATCH_LENGTH..],
std::mem::take(&mut confirmations),
chunk.len(),
(stream, topic),
)
})?;
confirmations.extend(response.confirmations);
}
Ok(SendMessagesResponse { confirmations })
}
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(code = code, operation = "managed"))]
pub(crate) async fn send_raw_with_response(
&self,
code: u32,
payload: Vec<u8>,
) -> Result<Vec<u8>, IggyError> {
let payload = if laser_wire::codes::is_idempotent_managed_request(code) {
laser_wire::framing::encode_named(&laser_wire::mutation::ManagedRequestEnvelope {
v: laser_wire::mutation::MANAGED_REQUEST_VERSION,
operation_id: u128::from(ulid::Ulid::generate()),
payload,
})
.map_err(|_| IggyError::InvalidFormat)?
} else {
payload
};
let reply = self
.client()
.send_binary_request(code, bytes::Bytes::from(payload))
.await?;
Ok(reply.to_vec())
}
#[cfg(feature = "kv")]
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(code = code, operation = "managed_preframed"))]
pub(crate) async fn send_raw_preframed(
&self,
code: u32,
payload: Vec<u8>,
) -> Result<Vec<u8>, IggyError> {
let reply = self
.client()
.send_binary_request(code, bytes::Bytes::from(payload))
.await?;
Ok(reply.to_vec())
}
pub(crate) fn iggy_producer(
&self,
stream: &str,
topic: &str,
) -> Result<IggyProducerBuilder, LaserError> {
Ok(self.client().producer(stream, topic)?)
}
pub(crate) fn iggy_consumer(
&self,
name: &str,
stream: &str,
topic: &str,
partition: u32,
) -> Result<IggyConsumerBuilder, LaserError> {
Ok(self.client().consumer(name, stream, topic, partition)?)
}
pub(crate) fn iggy_consumer_group(
&self,
group: &str,
stream: &str,
topic: &str,
) -> Result<IggyConsumerBuilder, LaserError> {
Ok(self.client().consumer_group(group, stream, topic)?)
}
pub(crate) async fn producer_on(
&self,
stream: &str,
topic: &str,
) -> Result<Arc<IggyProducer>, LaserError> {
let cell = self
.inner
.producers
.entry((stream.to_owned(), topic.to_owned()))
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone();
let producer = cell
.get_or_try_init(|| async {
let producer = self
.client()
.producer(stream, topic)?
.direct(
DirectConfig::builder()
.batch_length(PUBLISH_BATCH_LENGTH as u32)
.build(),
)
.send_retries(Some(0), None)
.build();
init_producer(&producer).await?;
Ok::<_, LaserError>(Arc::new(producer))
})
.await?;
Ok(producer.clone())
}
}
#[cfg(all(feature = "agent", any(feature = "query", test)))]
fn claim_presence_slot(
slot: &std::sync::Mutex<Option<crate::types::AgentId>>,
requested: crate::types::AgentId,
) -> Result<(), LaserError> {
let mut advertised = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match advertised.as_ref() {
Some(current) if current != &requested => Err(LaserError::PresenceConflict {
advertised: current.to_string(),
requested: requested.to_string(),
}),
Some(_) => Ok(()),
None => {
*advertised = Some(requested);
Ok(())
}
}
}
pub(crate) fn publish_failure(
error: LaserError,
pending: &[IggyMessage],
mut confirmations: Vec<SendMessagesConfirmationResponse>,
attempted: usize,
target: (&str, &str),
) -> LaserError {
let unsent = pending.get(attempted..).unwrap_or_default();
let (source, stream, topic, unconfirmed) = match error {
LaserError::Iggy(IggyError::ProducerSendFailed {
cause,
failed,
committed,
stream_name,
topic_name,
}) => {
confirmations.extend(committed.iter().cloned());
let unconfirmed = failed
.iter()
.chain(unsent)
.map(clone_iggy_message)
.collect();
(
LaserError::Iggy(*cause),
stream_name,
topic_name,
unconfirmed,
)
}
error => (
error,
target.0.to_owned(),
target.1.to_owned(),
pending.iter().map(clone_iggy_message).collect(),
),
};
LaserError::PublishFailed(Box::new(crate::error::PublishFailure {
source,
stream,
topic,
committed: confirmations,
unconfirmed,
}))
}
pub(crate) fn prepare_publish_messages(messages: &mut [IggyMessage]) {
for message in messages {
if message.header.id == 0 {
message.header.id = u128::from(ulid::Ulid::generate());
}
}
}
pub(crate) fn clone_iggy_message(message: &IggyMessage) -> IggyMessage {
IggyMessage {
header: IggyMessageHeader {
checksum: message.header.checksum,
id: message.header.id,
offset: message.header.offset,
timestamp: message.header.timestamp,
origin_timestamp: message.header.origin_timestamp,
user_headers_length: message.header.user_headers_length,
payload_length: message.header.payload_length,
reserved: message.header.reserved,
},
payload: message.payload.clone(),
user_headers: message.user_headers.clone(),
}
}
pub(crate) async fn init_producer(producer: &IggyProducer) -> Result<(), IggyError> {
for attempt in 0..TRANSIENT_SEND_ATTEMPTS {
match producer.init().await {
Err(error)
if is_idempotent_create_race(&error) && attempt + 1 < TRANSIENT_SEND_ATTEMPTS =>
{
sleep(Duration::from_millis(50 * (attempt + 1) as u64)).await;
}
result => return result,
}
}
unreachable!("producer init retry returns success or the last error")
}
fn is_idempotent_create_race(error: &IggyError) -> bool {
matches!(
error,
IggyError::StreamNameAlreadyExists(_) | IggyError::TopicNameAlreadyExists(_, _)
)
}
#[derive(Default)]
pub struct LaserBuilder {
connection: ConnectionConfig,
connection_conflict: Option<&'static str>,
connect_timeout: Option<Duration>,
publish_timeout: Option<Duration>,
publish_max_retries: Option<u32>,
publish_retry_backoff: Option<Duration>,
stream: Option<String>,
resource_naming: ResourceNaming,
ops_stream: Option<String>,
control_topic: Option<String>,
dlq_topic: Option<String>,
changes_topic: Option<String>,
capabilities: Option<Capabilities>,
#[cfg(feature = "sign")]
verifier: Option<Arc<crate::sign::KeyRegistry>>,
#[cfg(feature = "agent")]
governor: Option<Arc<crate::govern::GovernorState>>,
}
#[derive(Default)]
enum ConnectionConfig {
#[default]
Unset,
ConnectionString(String),
Tcp {
address: String,
username: String,
password: String,
},
Client(IggyClient),
}
impl LaserBuilder {
pub fn connect_timeout(mut self, value: Duration) -> Self {
self.connect_timeout = Some(value);
self
}
pub fn publish_timeout(mut self, value: Duration) -> Self {
self.publish_timeout = Some(value);
self
}
pub fn publish_max_retries(mut self, value: u32) -> Self {
self.publish_max_retries = Some(value);
self
}
pub fn publish_retry_backoff(mut self, value: Duration) -> Self {
self.publish_retry_backoff = Some(value);
self
}
pub fn connection_string(mut self, value: impl Into<String>) -> Self {
if matches!(
self.connection,
ConnectionConfig::Tcp { .. } | ConnectionConfig::Client(_)
) {
self.connection_conflict = Some(
"connection_string() conflicts with an address/credentials or client already set",
);
}
self.connection = ConnectionConfig::ConnectionString(value.into());
self
}
pub fn address(mut self, value: impl Into<String>) -> Self {
if matches!(
self.connection,
ConnectionConfig::ConnectionString(_) | ConnectionConfig::Client(_)
) {
self.connection_conflict =
Some("address() conflicts with a connection_string or client already set");
}
match self.connection {
ConnectionConfig::Tcp {
username, password, ..
} => {
self.connection = ConnectionConfig::Tcp {
address: value.into(),
username,
password,
};
}
_ => {
self.connection = ConnectionConfig::Tcp {
address: value.into(),
username: String::new(),
password: String::new(),
};
}
}
self
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
if matches!(
self.connection,
ConnectionConfig::ConnectionString(_) | ConnectionConfig::Client(_)
) {
self.connection_conflict =
Some("credentials() conflicts with a connection_string or client already set");
}
match self.connection {
ConnectionConfig::Tcp { address, .. } => {
self.connection = ConnectionConfig::Tcp {
address,
username: username.into(),
password: password.into(),
};
}
_ => {
self.connection = ConnectionConfig::Tcp {
address: String::new(),
username: username.into(),
password: password.into(),
};
}
}
self
}
pub fn client(mut self, client: IggyClient) -> Self {
if matches!(
self.connection,
ConnectionConfig::ConnectionString(_) | ConnectionConfig::Tcp { .. }
) {
self.connection_conflict = Some(
"client() conflicts with a connection_string or address/credentials already set",
);
}
self.connection = ConnectionConfig::Client(client);
self
}
pub fn stream(mut self, value: impl Into<String>) -> Self {
self.stream = Some(value.into());
self
}
pub fn resource_naming(mut self, value: ResourceNaming) -> Self {
self.resource_naming = value;
self
}
pub fn capabilities(mut self, value: Capabilities) -> Self {
self.capabilities = Some(value);
self
}
#[cfg(feature = "sign")]
pub fn verifier(mut self, verifier: Arc<crate::sign::KeyRegistry>) -> Self {
self.verifier = Some(verifier);
self
}
#[cfg(feature = "agent")]
pub fn governor(
mut self,
governor: Arc<dyn crate::govern::ActionGovernor>,
mode: crate::govern::GovernorMode,
) -> Self {
self.governor = Some(Arc::new(crate::govern::GovernorState::new(governor, mode)));
self
}
#[cfg(feature = "agent")]
pub fn governor_with_retention(
mut self,
governor: Arc<dyn crate::govern::ActionGovernor>,
mode: crate::govern::GovernorMode,
retention: crate::govern::GovernorRetention,
) -> Self {
self.governor = Some(Arc::new(crate::govern::GovernorState::with_retention(
governor, mode, retention,
)));
self
}
pub fn ops_stream(mut self, value: impl Into<String>) -> Self {
self.ops_stream = Some(value.into());
self
}
pub fn control_topic(mut self, value: impl Into<String>) -> Self {
self.control_topic = Some(value.into());
self
}
pub fn dlq_topic(mut self, value: impl Into<String>) -> Self {
self.dlq_topic = Some(value.into());
self
}
pub fn changes_topic(mut self, value: impl Into<String>) -> Self {
self.changes_topic = Some(value.into());
self
}
#[allow(unused_mut)]
pub async fn build(mut self) -> Result<Laser, LaserError> {
if let Some(conflict) = self.connection_conflict {
return Err(LaserError::Config(conflict));
}
let publish_options = PublishOptions::from_env(
self.publish_timeout,
self.publish_max_retries,
self.publish_retry_backoff,
)?;
let connect_deadline =
tokio::time::Instant::now() + ConnectOptions::from_env(self.connect_timeout)?.timeout;
let stream = self.stream.filter(|value| !value.is_empty());
let (client, connection_string) = match self.connection {
ConnectionConfig::Unset => {
return Err(LaserError::Config(
"connection_string, address+credentials, or client is required",
));
}
ConnectionConfig::ConnectionString(value) => {
let normalized = normalize_connection_string(&value)?;
let client = IggyClientBuilder::from_connection_string(&normalized)?.build()?;
connect_before(&client, connect_deadline).await?;
(client, Some(normalized))
}
ConnectionConfig::Tcp {
address,
username,
password,
} => {
if address.is_empty() {
return Err(LaserError::Config("address is required"));
}
if username.is_empty() {
return Err(LaserError::Config("credentials are required"));
}
let with_tls = normalize_connection_string(&format!(
"iggy+tcp://{username}:{password}@{address}"
))?;
let client = IggyClientBuilder::from_connection_string(&with_tls)?.build()?;
connect_before(&client, connect_deadline).await?;
(client, Some(with_tls))
}
ConnectionConfig::Client(client) => (client, None),
};
let configured_capabilities = self.capabilities.clone().unwrap_or(Capabilities::OPEN);
let mut capabilities = configured_capabilities.clone();
let mut topology = laser_wire::topology::WireTopology::default();
match tokio::time::timeout_at(connect_deadline, probe_managed_host(&client)).await {
Ok(Ok(Some(announce))) => {
if let Some(announced) = announce.topology.clone() {
topology = announced;
}
merge_announcement(&mut capabilities, &announce);
capabilities.hello = HelloOutcome::Answered;
}
Ok(Ok(None)) => capabilities.hello = HelloOutcome::Rejected,
Ok(Err(())) | Err(_) => capabilities.hello = HelloOutcome::Failed,
}
Ok(Laser {
inner: Arc::new(LaserInner {
client: std::sync::RwLock::new(Arc::new(client)),
publish_options,
reconnect_gate: tokio::sync::Mutex::new(()),
publish_generation: std::sync::atomic::AtomicU64::new(0),
connection_string,
#[cfg(feature = "kv")]
coordination: OnceCell::new(),
#[cfg(feature = "kv")]
coordination_acquire_gate: tokio::sync::Mutex::new(()),
producers: DashMap::new(),
producer_statistics: Default::default(),
negotiated: std::sync::RwLock::new(NegotiatedState {
configured_capabilities,
capabilities,
topology,
probed_at: Some(tokio::time::Instant::now()),
}),
reprobe_gate: tokio::sync::Mutex::new(()),
#[cfg(feature = "agent")]
registry_caches: DashMap::new(),
#[cfg(feature = "agent")]
leases: Arc::default(),
#[cfg(feature = "agent")]
layouts: DashMap::new(),
#[cfg(feature = "agent")]
#[cfg(all(feature = "agent", any(feature = "query", test)))]
advertised_agent: std::sync::Mutex::new(None),
#[cfg(feature = "agent")]
reply_hubs: DashMap::new(),
#[cfg(feature = "sign")]
verifier: self.verifier,
}),
capability_override: None,
ops_stream_override: self.ops_stream,
control_topic_override: self.control_topic,
dlq_topic_override: self.dlq_topic,
changes_topic_override: self.changes_topic,
stream,
resource_naming: self.resource_naming,
#[cfg(feature = "agent")]
governor: self.governor,
})
}
}
async fn probe_managed_host(
client: &IggyClient,
) -> Result<Option<laser_wire::hello::BackendAnnounce>, ()> {
match client
.send_binary_request(laser_wire::codes::AGDX_HELLO_CODE, Bytes::new())
.await
{
Ok(reply) if reply.is_empty() => Ok(None),
Ok(reply) => decode_named::<laser_wire::hello::BackendAnnounce>(&reply)
.ok()
.filter(|announce| announce.validate().is_ok())
.map(Some)
.ok_or(()),
Err(IggyError::InvalidCommand) => Ok(None),
Err(_) => Err(()),
}
}
fn merge_announcement(
capabilities: &mut Capabilities,
announce: &laser_wire::hello::BackendAnnounce,
) {
let versions = announce.versions;
capabilities.versions = Some(versions);
capabilities.backends.clone_from(&announce.backends);
capabilities.authz |= versions.has_feature(laser_wire::hello::feature::AUTHZ);
capabilities.filters.native |=
versions.has_feature(laser_wire::hello::feature::CONSUMER_FILTERS);
capabilities.filters.group_policy_reads |=
versions.has_feature(laser_wire::hello::feature::GROUP_POLICY_READS);
if capabilities.filters.native {
capabilities
.filters
.evaluation
.clone_from(&announce.filters);
}
if announce.ready {
capabilities.managed = true;
capabilities.filters.catalog |= (capabilities.filters.native
|| capabilities.filters.group_policy_reads)
&& versions.filter > 0;
capabilities.kv.available |= versions.kv > 0;
capabilities.forks |= versions.fork > 0;
capabilities.graph |= versions.graph > 0;
capabilities.destinations.available |= versions.checkpoint > 0
&& versions.has_feature(laser_wire::hello::feature::DESTINATIONS);
capabilities.merge_features(&versions);
for backend in announce
.backends
.iter()
.filter(|backend| backend.readiness.ready)
{
if let Some(query) = &backend.query {
capabilities.query.available |= versions.query > 0;
capabilities.query.cursor_paging |= query
.paging
.contains(&laser_wire::hello::QueryPagingCapability::Cursor);
capabilities.query.cancellation |= query.cancellation;
capabilities.query.execution_status |= query.execution_status;
if query
.consistency
.contains(&laser_wire::query::Consistency::Strong)
{
capabilities.query.consistency = laser_wire::query::Consistency::Strong;
} else if query
.consistency
.contains(&laser_wire::query::Consistency::ReadYourWrites)
{
capabilities.query.consistency = capabilities
.query
.consistency
.max(laser_wire::query::Consistency::ReadYourWrites);
}
}
}
}
}
#[cfg(all(
test,
any(
feature = "filters",
feature = "fork",
feature = "graph",
feature = "kv",
feature = "projections",
feature = "query",
feature = "rbac"
)
))]
mod announcement_tests {
use super::*;
use laser_wire::destination::BackendResourceId;
use laser_wire::hello::{
BackendAnnounce, BackendDescriptor, BackendDesiredState, BackendImplementation,
BackendMode, BackendObservedState, BackendReadiness, OpVersions, QueryCapabilities,
QueryPagingCapability, feature,
};
fn backend(id: u128, ready: bool) -> BackendDescriptor {
let backend = BackendDescriptor::new(
BackendResourceId::from_u128(id),
BackendMode::Operational,
"Embedded",
BackendImplementation {
kind: "embedded".to_owned(),
version: "1.0.0".to_owned(),
},
1,
1,
);
if !ready {
return backend;
}
backend
.with_state(
BackendDesiredState::Enabled,
BackendObservedState::Ready,
BackendReadiness::ready(1),
)
.with_query(QueryCapabilities {
dialects: vec![laser_wire::query::SqlDialect::Sqlite],
time_travel: Vec::new(),
consistency: vec![laser_wire::query::Consistency::Eventual],
logical_types: vec![laser_wire::schema::LogicalTypeKind::String],
paging: vec![QueryPagingCapability::Cursor],
cancellation: true,
execution_status: true,
raw_sql: true,
})
.with_limits(laser_wire::hello::BackendLimits {
max_query_rows: 1_000,
max_query_bytes: 1_000_000,
max_scan_bytes: 10_000_000,
max_query_micros: 30_000_000,
max_concurrent_queries: 8,
max_schema_fields: 0,
max_materialization_file_bytes: 0,
})
}
#[test]
fn given_consumer_filters_without_a_plane_when_merged_then_should_serve_native_filters_only() {
let announce = BackendAnnounce::new(
OpVersions::new(0, 0, 0, 0).with_features(feature::CONSUMER_FILTERS | feature::AUTHZ),
)
.unavailable();
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(capabilities.filters.native);
assert!(!capabilities.filters.catalog);
assert!(!capabilities.managed);
}
#[test]
fn given_consumer_filters_and_a_ready_catalog_when_merged_then_should_serve_both() {
let announce = BackendAnnounce::new(
OpVersions::new(1, 1, 1, 1)
.with_filter(laser_wire::codes::FILTER_OP_VERSION)
.with_features(feature::CONSUMER_FILTERS),
)
.with_backends(vec![backend(1, true)]);
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(capabilities.filters.native);
assert!(capabilities.filters.catalog);
}
#[test]
fn given_an_unavailable_backend_when_merged_then_should_not_enable_plane_surfaces() {
let announce = BackendAnnounce::new(
OpVersions::new(1, 1, 1, 1)
.with_agent(1)
.with_graph(1)
.with_features(
feature::KV_CAS
| feature::KV_CAS_FENCED
| feature::STRONG_CONSISTENCY
| feature::SESSIONS
| feature::KEYWORD_SEARCH
| feature::WATCH
| feature::AUTHZ,
),
)
.with_backends(vec![backend(1, false)])
.unavailable();
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(!capabilities.managed);
assert!(!capabilities.query.available);
assert!(!capabilities.query.keyword);
assert!(!capabilities.kv.available);
assert!(!capabilities.kv.cas);
assert!(!capabilities.kv.cas_fenced);
assert!(!capabilities.graph);
assert!(!capabilities.forks);
assert!(!capabilities.sessions);
assert!(!capabilities.watch);
assert!(capabilities.authz, "server-native authz remains available");
assert_eq!(capabilities.backends, announce.backends);
assert_eq!(capabilities.versions, Some(announce.versions));
}
#[test]
fn given_a_ready_backend_when_merged_then_should_enable_advertised_plane_surfaces() {
let announce = BackendAnnounce::new(
OpVersions::new(1, 1, 1, 1)
.with_graph(1)
.with_features(feature::KV_CAS | feature::SESSIONS),
)
.with_backends(vec![backend(1, true)]);
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(capabilities.managed);
assert!(capabilities.query.available);
assert!(capabilities.kv.available);
assert!(capabilities.kv.cas);
assert!(capabilities.graph);
assert!(capabilities.forks);
assert!(capabilities.sessions);
assert_eq!(capabilities.backends, announce.backends);
}
}
const DEFAULT_TCP_PORT: u16 = 8090;
const CONNECTION_OPTIONS: [&str; 8] = [
"tls",
"tls_domain",
"tls_ca_file",
"reconnection_retries",
"reconnection_interval",
"reestablish_after",
"heartbeat_interval",
"nodelay",
];
fn normalize_connection_string(value: &str) -> Result<String, LaserError> {
let trimmed = value.trim();
let (scheme, rest) = split_scheme(trimmed)?;
let authority = authority_start(rest);
if authority == 0 {
return Err(LaserError::Config(
"connection string missing credentials: use user:password@host or token@host",
));
}
let userinfo = &rest[..authority - 1];
validate_credentials(userinfo)?;
let (address, query) = match rest[authority..].split_once('?') {
Some((address, query)) => (address, Some(query)),
None => (&rest[authority..], None),
};
let address = with_default_port(address)?;
let mut normalized = format!("{scheme}{userinfo}@{address}");
if let Some(query) = query {
let options = validate_options(query)?;
normalized = format!("{normalized}?{query}");
if options.contains(&"tls_ca_file") && !options.contains(&"tls") {
normalized.push_str("&tls=true");
}
}
resolve_tls(normalized)
}
fn split_scheme(value: &str) -> Result<(&'static str, &str), LaserError> {
if let Some(rest) = value.strip_prefix("iggy://") {
return Ok(("iggy://", rest));
}
if let Some(rest) = value.strip_prefix("iggy+tcp://") {
return Ok(("iggy+tcp://", rest));
}
match value.split_once("://") {
Some((scheme, _)) if !scheme.contains([':', '@']) => Err(LaserError::Config(
"unsupported connection string scheme: use iggy:// or iggy+tcp://",
)),
_ => Ok(("iggy://", value)),
}
}
fn validate_credentials(userinfo: &str) -> Result<(), LaserError> {
if userinfo.contains('@') {
return Err(LaserError::Config(
"connection string credentials cannot contain `@`",
));
}
let mut parts = userinfo.split(':');
let first = parts.next().unwrap_or_default();
match (parts.next(), parts.next()) {
(None, _) if first.is_empty() => Err(LaserError::Config(
"connection string has an empty access token",
)),
(None, _) => Ok(()),
(Some(password), None) if first.is_empty() || password.is_empty() => Err(
LaserError::Config("connection string has an empty username or password"),
),
(Some(_), None) => Ok(()),
(Some(_), Some(_)) => Err(LaserError::Config(
"connection string credentials cannot contain more than one `:`",
)),
}
}
fn with_default_port(address: &str) -> Result<String, LaserError> {
if address.contains(['[', ']', '/', '#']) {
return Err(LaserError::Config(
"connection string address must be host or host:port, IPv6 literals and paths are not supported",
));
}
let (host, port) = match address.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (address, None),
};
if host.is_empty() {
return Err(LaserError::Config("connection string missing host"));
}
match port {
None => Ok(format!("{host}:{DEFAULT_TCP_PORT}")),
Some(port) => match port.parse::<u16>() {
Ok(number) if number > 0 && !port.starts_with('+') => Ok(format!("{host}:{number}")),
_ => Err(LaserError::Config(
"connection string port must be a number from 1 to 65535",
)),
},
}
}
fn validate_options(query: &str) -> Result<Vec<&str>, LaserError> {
let mut keys = Vec::new();
let mut tls_off = false;
for pair in query.split('&') {
let mut parts = pair.split('=');
let (Some(key), Some(value), None) = (parts.next(), parts.next(), parts.next()) else {
return Err(LaserError::Config(
"connection string option must be key=value",
));
};
if !CONNECTION_OPTIONS.contains(&key) {
return Err(LaserError::Config(
"connection string has an unknown option: supported are tls, tls_domain, tls_ca_file, reconnection_retries, reconnection_interval, reestablish_after, heartbeat_interval, nodelay",
));
}
if keys.contains(&key) {
return Err(LaserError::Config("connection string repeats an option"));
}
if matches!(key, "tls" | "nodelay") && !matches!(value, "true" | "false") {
return Err(LaserError::Config(
"connection string tls and nodelay take true or false",
));
}
tls_off |= key == "tls" && value == "false";
keys.push(key);
}
if tls_off && keys.contains(&"tls_ca_file") {
return Err(LaserError::Config(
"connection string sets tls=false together with tls_ca_file",
));
}
Ok(keys)
}
fn is_laserdata_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
host == "laserdata.cloud"
|| host.ends_with(".laserdata.cloud")
|| host == "laserdata.com"
|| host.ends_with(".laserdata.com")
}
fn after_scheme(connection_string: &str) -> &str {
connection_string
.split_once("://")
.map_or(connection_string, |(_, rest)| rest)
}
fn authority_start(after_scheme: &str) -> usize {
let before_query = after_scheme
.find('?')
.map_or(after_scheme, |query| &after_scheme[..query]);
before_query.rfind('@').map_or(0, |at| at + 1)
}
pub(crate) fn endpoint_of(connection_string: &str) -> &str {
let after_scheme = after_scheme(connection_string);
let host_and_port = &after_scheme[authority_start(after_scheme)..];
host_and_port
.split(['/', '?', '#'])
.next()
.unwrap_or(host_and_port)
}
pub(crate) fn host_of(connection_string: &str) -> &str {
let after_scheme = after_scheme(connection_string);
let host_and_port = &after_scheme[authority_start(after_scheme)..];
let authority = host_and_port
.split(['/', '?', '#'])
.next()
.unwrap_or(host_and_port);
if let Some(bracketed) = authority.strip_prefix('[')
&& let Some(closing) = bracketed.find(']')
{
return &bracketed[..closing];
}
authority
.rsplit_once(':')
.map_or(authority, |(host, _)| host)
}
fn query_of(connection_string: &str) -> Option<&str> {
let after_scheme = after_scheme(connection_string);
let authority = &after_scheme[authority_start(after_scheme)..];
authority.split_once('?').map(|(_, query)| query)
}
fn has_query_param(connection_string: &str, key: &str) -> bool {
query_of(connection_string).is_some_and(|query| {
query.split('&').any(|pair| {
let name = pair.split_once('=').map_or(pair, |(name, _)| name);
name.eq_ignore_ascii_case(key)
})
})
}
pub(crate) fn with_endpoint(connection_string: &str, endpoint: &str) -> String {
let rest = after_scheme(connection_string);
let start = connection_string.len() - rest.len() + authority_start(rest);
let end = connection_string[start..]
.find(['/', '?', '#'])
.map_or(connection_string.len(), |length| start + length);
let rewritten = format!(
"{}{endpoint}{}",
&connection_string[..start],
&connection_string[end..]
);
let tls = query_of(connection_string).is_some_and(|query| {
query
.split('&')
.any(|pair| pair.eq_ignore_ascii_case("tls=true"))
});
if !tls || has_query_param(connection_string, "tls_domain") {
return rewritten;
}
format!("{rewritten}&tls_domain={}", host_of(connection_string))
}
fn flag_value_enabled(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name).is_ok_and(|value| flag_value_enabled(&value))
}
fn resolve_tls(connection_string: String) -> Result<String, LaserError> {
let custom_cert = std::env::var("LASER_TLS_CERT")
.ok()
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from);
resolve_tls_with(
connection_string,
env_flag_enabled("LASER_NO_TLS"),
custom_cert,
)
}
fn resolve_tls_with(
connection_string: String,
no_tls: bool,
custom_cert: Option<std::path::PathBuf>,
) -> Result<String, LaserError> {
if no_tls
|| has_query_param(&connection_string, "tls_ca_file")
|| query_of(&connection_string)
.is_some_and(|query| query.split('&').any(|pair| pair == "tls=false"))
{
return Ok(connection_string);
}
if custom_cert.is_none() && !is_laserdata_host(host_of(&connection_string)) {
return Ok(connection_string);
}
let cert_path = custom_cert.map_or_else(resolve_cert_path, Ok)?;
let mut with_tls = connection_string;
if !has_query_param(&with_tls, "tls") {
let separator = if query_of(&with_tls).is_some() {
'&'
} else {
'?'
};
with_tls = format!("{with_tls}{separator}tls=true");
}
Ok(format!("{with_tls}&tls_ca_file={}", cert_path.display()))
}
static PROD_CERT: &[u8] = include_bytes!("../certs/laserdata.crt");
fn cert_cache_dir() -> Result<std::path::PathBuf, LaserError> {
#[cfg(windows)]
let base = std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from);
#[cfg(target_os = "macos")]
let base =
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join("Library/Caches"));
#[cfg(all(unix, not(target_os = "macos")))]
let base = std::env::var_os("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".cache"))
});
let base = base.ok_or(LaserError::Config(
"no per-user cache directory to store the LaserData CA in: set LASER_TLS_CERT to a CA file path",
))?;
Ok(base.join("laser-sdk"))
}
#[cfg(unix)]
fn restrict_to_owner(dir: &std::path::Path) -> Result<(), LaserError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(|error| LaserError::Invalid(format!("restrict CA cache directory: {error}")))
}
#[cfg(not(unix))]
fn restrict_to_owner(_dir: &std::path::Path) -> Result<(), LaserError> {
Ok(())
}
fn write_cert(dir: &std::path::Path, path: &std::path::Path) -> Result<(), LaserError> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static ATTEMPT: AtomicU64 = AtomicU64::new(0);
let attempt = ATTEMPT.fetch_add(1, Ordering::Relaxed);
let temp = dir.join(format!(
"laserdata.crt.{}.{attempt}.tmp",
std::process::id()
));
let _ = std::fs::remove_file(&temp);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temp)
.map_err(|error| LaserError::Invalid(format!("create CA cert: {error}")))?;
file.write_all(PROD_CERT)
.and_then(|()| file.sync_all())
.map_err(|error| LaserError::Invalid(format!("write CA cert: {error}")))?;
drop(file);
std::fs::rename(&temp, path)
.map_err(|error| LaserError::Invalid(format!("install CA cert: {error}")))
}
fn install_cert(dir: &std::path::Path) -> Result<std::path::PathBuf, LaserError> {
std::fs::create_dir_all(dir)
.map_err(|error| LaserError::Invalid(format!("create CA cache directory: {error}")))?;
restrict_to_owner(dir)?;
let path = dir.join("laserdata.crt");
if std::fs::read(&path).is_ok_and(|cached| cached == PROD_CERT) {
return Ok(path);
}
write_cert(dir, &path)?;
Ok(path)
}
fn resolve_cert_path() -> Result<std::path::PathBuf, LaserError> {
if let Ok(custom) = std::env::var("LASER_TLS_CERT")
&& !custom.is_empty()
{
return Ok(std::path::PathBuf::from(custom));
}
install_cert(&cert_cache_dir()?)
}
pub(crate) async fn ensure_stream(client: &IggyClient, stream: &str) -> Result<(), LaserError> {
if client
.get_stream(&Identifier::named(stream)?)
.await?
.is_some()
{
return Ok(());
}
if let Err(error) = client.create_stream(stream).await
&& client
.get_stream(&Identifier::named(stream)?)
.await?
.is_none()
{
return Err(error.into());
}
Ok(())
}
fn is_missing_resource(error: &IggyError) -> bool {
matches!(
error,
IggyError::ResourceNotFound(_)
| IggyError::StreamIdNotFound(_)
| IggyError::StreamNameNotFound(_)
| IggyError::TopicIdNotFound(_, _)
| IggyError::TopicNameNotFound(_, _)
)
}
pub(crate) async fn delete_stream(laser: &Laser, stream: &str) -> Result<bool, LaserError> {
let client = laser.client();
let identifier = Identifier::named(stream)?;
let existed = client.get_stream(&identifier).await?.is_some();
if existed
&& let Err(error) = client.delete_stream(&identifier).await
&& client.get_stream(&identifier).await?.is_some()
{
return Err(error.into());
}
laser
.inner
.producers
.retain(|(producer_stream, _), _| producer_stream != stream);
#[cfg(feature = "agent")]
{
laser.inner.registry_caches.remove(stream);
laser
.inner
.reply_hubs
.retain(|(hub_stream, _), _| hub_stream != stream);
}
Ok(existed)
}
pub(crate) async fn ensure_topic(
client: &IggyClient,
stream: &str,
topic: &str,
partitions: u32,
) -> Result<(), LaserError> {
ensure_topic_with(client, stream, topic, partitions, IggyExpiry::NeverExpire).await
}
pub(crate) async fn ensure_topic_with(
client: &IggyClient,
stream: &str,
topic: &str,
partitions: u32,
expiry: IggyExpiry,
) -> Result<(), LaserError> {
ensure_topic_retained(
client,
stream,
topic,
partitions,
expiry,
MaxTopicSize::ServerDefault,
)
.await
}
pub(crate) async fn ensure_topic_retained(
client: &IggyClient,
stream: &str,
topic: &str,
partitions: u32,
expiry: IggyExpiry,
max_size: MaxTopicSize,
) -> Result<(), LaserError> {
let stream_id = Identifier::named(stream)?;
let topic_id = Identifier::named(topic)?;
if client.get_topic(&stream_id, &topic_id).await?.is_some() {
return Ok(());
}
let options = TopicCreateOptions {
partitions_count: Some(partitions),
compression_algorithm: Some(CompressionAlgorithm::default()),
message_expiry: Some(expiry),
max_topic_size: Some(max_size),
..TopicCreateOptions::default()
};
let result = client.create_topic(&stream_id, topic, &options).await;
if let Err(error) = result
&& client.get_topic(&stream_id, &topic_id).await?.is_none()
{
return Err(error.into());
}
Ok(())
}
#[cfg(test)]
mod builder_conflict_tests {
#[cfg(feature = "agent")]
use super::claim_presence_slot;
use super::{Laser, clone_iggy_message, prepare_publish_messages};
use crate::error::LaserError;
use bytes::Bytes;
use iggy::prelude::IggyMessage;
#[cfg(feature = "agent")]
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::net::TcpListener;
#[cfg(feature = "agent")]
#[test]
fn given_one_connection_when_two_agents_claim_presence_then_should_reject_the_second() {
let slot = Mutex::new(None);
claim_presence_slot(&slot, "risk".parse().expect("risk is a valid agent id"))
.expect("the first agent claims the connection");
claim_presence_slot(&slot, "risk".parse().expect("risk is a valid agent id"))
.expect("the same agent may refresh its presence");
let error = claim_presence_slot(
&slot,
"support".parse().expect("support is a valid agent id"),
)
.expect_err("a second agent must not overwrite connection presence");
assert!(matches!(
error,
LaserError::PresenceConflict { advertised, requested }
if advertised == "risk" && requested == "support"
));
}
#[test]
fn given_a_publish_batch_when_cloned_for_retry_then_should_preserve_message_identity() {
let message = IggyMessage::builder()
.payload(Bytes::from_static(b"retry-body"))
.build()
.expect("the retry fixture message builds");
let mut messages = vec![message];
prepare_publish_messages(&mut messages);
let retry = clone_iggy_message(&messages[0]);
assert_ne!(retry.header.id, 0);
assert_eq!(retry.header.id, messages[0].header.id);
assert_eq!(retry.payload, Bytes::from_static(b"retry-body"));
assert_eq!(retry.user_headers, messages[0].user_headers);
}
#[test]
fn given_a_failed_chunk_when_reporting_progress_then_should_keep_confirmations_and_unsent_tail()
{
use iggy::prelude::{IggyError, SendMessagesConfirmationResponse};
use std::sync::Arc;
let message = |id| {
IggyMessage::builder()
.id(id)
.payload(Bytes::from_static(b"body"))
.build()
.expect("message builds")
};
let confirmed = SendMessagesConfirmationResponse {
stream_id: 1,
topic_id: 2,
partition_id: 0,
base_offset: 9,
};
let error = super::publish_failure(
LaserError::Iggy(IggyError::ProducerSendFailed {
cause: Box::new(IggyError::Disconnected),
failed: Arc::new(vec![message(2)]),
committed: Arc::new(vec![]),
stream_name: "stream".to_owned(),
topic_name: "topic".to_owned(),
}),
&[message(1), message(2), message(3)],
vec![confirmed],
2,
("other", "other"),
);
assert!(error.is_retryable());
assert_eq!(
error.to_string(),
"publish failed to stream/topic: Disconnected"
);
let LaserError::PublishFailed(failure) = error else {
panic!("preserve the structured publish error");
};
assert!(matches!(
failure.source,
LaserError::Iggy(IggyError::Disconnected)
));
assert_eq!(
failure
.unconfirmed
.iter()
.map(|message| message.header.id)
.collect::<Vec<_>>(),
vec![2, 3]
);
assert_eq!(failure.committed.len(), 1);
assert_eq!(failure.committed[0].base_offset, 9);
}
#[test]
fn given_a_partially_confirmed_batch_when_a_chunk_times_out_then_should_preserve_progress() {
let message = |id| {
IggyMessage::builder()
.id(id)
.payload(Bytes::from_static(b"body"))
.build()
.expect("message builds")
};
let error = super::publish_failure(
LaserError::Timeout("Iggy publish response"),
&[message(4), message(5), message(6)],
vec![iggy::prelude::SendMessagesConfirmationResponse {
stream_id: 1,
topic_id: 2,
partition_id: 0,
base_offset: 9,
}],
2,
("stream", "topic"),
);
assert!(error.is_retryable());
assert!(matches!(error.publish_cause(), LaserError::Timeout(_)));
let LaserError::PublishFailed(failure) = error else {
panic!("a timeout must retain batch progress");
};
assert_eq!(failure.committed.len(), 1);
assert_eq!(failure.committed[0].base_offset, 9);
assert_eq!(
failure
.unconfirmed
.iter()
.map(|message| message.header.id)
.collect::<Vec<_>>(),
vec![4, 5, 6]
);
assert_eq!(failure.stream, "stream");
assert_eq!(failure.topic, "topic");
}
#[tokio::test]
async fn given_two_connection_modes_when_built_then_should_error_before_connecting() {
let result = Laser::builder()
.connection_string("iggy:iggy@127.0.0.1:8090")
.address("127.0.0.1:8090")
.credentials("iggy", "iggy")
.build()
.await;
assert!(matches!(result, Err(LaserError::Config(_))));
}
#[tokio::test]
async fn given_a_server_that_never_answers_login_when_building_then_should_time_out_at_the_budget()
{
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind a local listener");
let address = listener.local_addr().expect("read the bound address");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept the client");
let mut buffer = [0_u8; 1024];
while socket.read(&mut buffer).await.is_ok_and(|read| read > 0) {}
});
let started = tokio::time::Instant::now();
let result = Laser::builder()
.connection_string(format!("iggy:iggy@{address}"))
.connect_timeout(Duration::from_millis(300))
.build()
.await;
assert!(matches!(
result,
Err(LaserError::Timeout("the Iggy login reply"))
));
assert!(started.elapsed() < Duration::from_secs(5));
server.abort();
}
#[tokio::test]
async fn given_a_zero_connect_timeout_when_building_then_should_reject_the_configuration() {
let result = Laser::builder()
.connection_string("iggy:iggy@127.0.0.1:8090")
.connect_timeout(Duration::ZERO)
.build()
.await;
assert!(matches!(result, Err(LaserError::Config(_))));
}
#[test]
fn given_an_unmanaged_set_when_it_ages_then_should_be_due_for_a_reprobe() {
let laser = Laser::from_client(iggy::prelude::IggyClient::default());
assert!(laser.reprobe_due(), "a set never probed is due");
let set_probe = |at: tokio::time::Instant, managed: bool| {
let mut negotiated = laser.inner.negotiated.write().expect("not poisoned");
negotiated.probed_at = Some(at);
negotiated.capabilities.managed = managed;
};
let now = tokio::time::Instant::now();
set_probe(now, false);
assert!(!laser.reprobe_due(), "a fresh unmanaged set is trusted");
let aged = now
.checked_sub(super::UNMANAGED_REPROBE_INTERVAL)
.expect("the clock is past one second");
set_probe(aged, false);
assert!(laser.reprobe_due(), "an aged unmanaged set is probed again");
set_probe(aged, true);
assert!(!laser.reprobe_due(), "a managed set is never probed again");
}
#[tokio::test]
async fn given_a_capability_override_when_read_then_should_not_probe() {
let mut capabilities = crate::capabilities::Capabilities::OPEN;
capabilities.graph = true;
let laser = Laser::from_client(iggy::prelude::IggyClient::default())
.with_capabilities(capabilities.clone());
assert_eq!(laser.capabilities().await, capabilities);
}
#[cfg(feature = "kv")]
#[tokio::test]
async fn given_a_closed_laser_when_acquiring_its_first_lease_then_should_not_open_a_connection()
{
let mut laser = Laser::from_client(iggy::prelude::IggyClient::default());
std::sync::Arc::get_mut(&mut laser.inner)
.expect("the Laser is not shared yet")
.connection_string = Some("not a connection string".to_owned());
let mut capabilities = crate::capabilities::Capabilities::OPEN;
capabilities.kv.fenced_leases = true;
let laser = laser.with_capabilities(capabilities);
laser.close().await.expect("close the unconnected client");
let result = laser
.kv("leases")
.lease(b"key", "holder", Duration::from_secs(1))
.await;
assert!(matches!(
result,
Err(LaserError::Iggy(iggy::prelude::IggyError::ClientShutdown))
));
}
}
#[cfg(test)]
mod connection_string_tests {
use super::{
LaserError, PROD_CERT, endpoint_of, flag_value_enabled, has_query_param, host_of,
install_cert, is_laserdata_host, normalize_connection_string, resolve_tls,
resolve_tls_with,
};
#[test]
fn given_another_node_when_aimed_at_then_should_keep_credentials_and_options() {
assert_eq!(
super::with_endpoint(
"iggy+tcp://user:p/ss@host:8090?heartbeat_interval=5s",
"10.0.0.7:8091"
),
"iggy+tcp://user:p/ss@10.0.0.7:8091?heartbeat_interval=5s",
);
}
#[test]
fn given_a_tls_connection_when_aimed_at_another_node_then_should_verify_the_original_host() {
assert_eq!(
super::with_endpoint(
"iggy+tcp://user:pass@edge.laserdata.cloud:8090?tls=true",
"10.0.0.7:8090"
),
"iggy+tcp://user:pass@10.0.0.7:8090?tls=true&tls_domain=edge.laserdata.cloud",
);
}
#[test]
fn given_a_full_tcp_connection_string_when_normalized_then_should_be_unchanged() {
assert_eq!(
normalize_connection_string("iggy+tcp://iggy:iggy@127.0.0.1:8090")
.expect("a local connection string normalizes"),
"iggy+tcp://iggy:iggy@127.0.0.1:8090",
);
}
#[test]
fn given_a_default_scheme_when_normalized_then_should_be_unchanged() {
assert_eq!(
normalize_connection_string("iggy://user:password@host:8090")
.expect("a local connection string normalizes"),
"iggy://user:password@host:8090",
);
}
#[test]
fn given_a_bare_endpoint_when_normalized_then_should_prepend_default_scheme() {
assert_eq!(
normalize_connection_string("user:password@host:8090")
.expect("a bare endpoint normalizes"),
"iggy://user:password@host:8090",
);
}
#[test]
fn given_whitespace_around_the_value_when_normalized_then_should_be_trimmed() {
assert_eq!(
normalize_connection_string(" iggy:iggy@host:8090 ")
.expect("a padded endpoint normalizes"),
"iggy://iggy:iggy@host:8090",
);
}
#[test]
fn given_an_address_without_a_port_when_normalized_then_should_default_to_8090() {
assert_eq!(
normalize_connection_string("iggy:iggy@localhost").expect("a portless address"),
"iggy://iggy:iggy@localhost:8090",
);
assert_eq!(
normalize_connection_string("iggy+tcp://token@127.0.0.1?nodelay=true")
.expect("a portless token address"),
"iggy+tcp://token@127.0.0.1:8090?nodelay=true",
);
}
#[test]
fn given_an_invalid_port_when_normalized_then_should_fail_as_config() {
for value in [
"iggy:iggy@host:0",
"iggy:iggy@host:65536",
"iggy:iggy@host:",
"iggy:iggy@host:+1",
"iggy:iggy@host:80:81",
"iggy:iggy@[::1]:8090",
"iggy:iggy@host:8090/path",
"iggy:iggy@:8090",
] {
assert!(
matches!(
normalize_connection_string(value),
Err(LaserError::Config(_))
),
"{value} must be refused"
);
}
}
#[test]
fn given_another_transport_scheme_when_normalized_then_should_fail_as_config() {
for value in [
"iggy+quic://iggy:iggy@host:8080",
"iggy+ws://iggy:iggy@host:8092",
"iggy+http://iggy:iggy@host:3000",
"tcp://iggy:iggy@host:8090",
] {
assert!(
matches!(
normalize_connection_string(value),
Err(LaserError::Config(_))
),
"{value} must be refused"
);
}
}
#[test]
fn given_missing_or_malformed_credentials_when_normalized_then_should_fail_as_config() {
for value in [
"127.0.0.1:8090",
"iggy://127.0.0.1:8090",
"@host:8090",
":pass@host:8090",
"user:@host:8090",
"a:b:c@host:8090",
"user:p@ss@host:8090",
] {
assert!(
matches!(
normalize_connection_string(value),
Err(LaserError::Config(_))
),
"{value} must be refused"
);
}
}
#[test]
fn given_unknown_repeated_or_malformed_options_when_normalized_then_should_fail_as_config() {
for value in [
"iggy:iggy@host:8090?foo=bar",
"iggy:iggy@host:8090?TLS=true",
"iggy:iggy@host:8090?tls=true&tls=false",
"iggy:iggy@host:8090?tls",
"iggy:iggy@host:8090?",
"iggy:iggy@host:8090?tls=yes",
"iggy:iggy@host:8090?nodelay=1",
"iggy:iggy@host:8090?tls=false&tls_ca_file=/ca.crt",
] {
assert!(
matches!(
normalize_connection_string(value),
Err(LaserError::Config(_))
),
"{value} must be refused"
);
}
}
#[test]
fn given_a_ca_file_without_tls_when_normalized_then_should_turn_tls_on() {
assert_eq!(
normalize_connection_string("iggy:iggy@host:8090?tls_ca_file=/ca.crt")
.expect("a CA file alone"),
"iggy://iggy:iggy@host:8090?tls_ca_file=/ca.crt&tls=true",
);
assert_eq!(
normalize_connection_string("iggy:iggy@host?tls=true&tls_ca_file=/ca.crt")
.expect("an explicit TLS flag"),
"iggy://iggy:iggy@host:8090?tls=true&tls_ca_file=/ca.crt",
);
}
#[test]
fn given_an_explicit_tls_false_when_resolving_tls_then_should_skip_automatic_tls() {
assert_eq!(
resolve_tls_with(
"iggy://u:p@h.laserdata.cloud:8090?tls=false".to_owned(),
false,
Some("/tmp/local-ca.crt".into()),
)
.expect("an explicit opt-out"),
"iggy://u:p@h.laserdata.cloud:8090?tls=false",
);
}
#[test]
fn given_laserdata_hosts_when_checked_then_should_match_both_domains() {
assert!(is_laserdata_host("laserdata.cloud"));
assert!(is_laserdata_host("starter-123.aws.laserdata.cloud"));
assert!(is_laserdata_host("LASERDATA.CLOUD"));
assert!(is_laserdata_host("laserdata.com"));
assert!(is_laserdata_host("api.laserdata.com"));
assert!(
!is_laserdata_host("laserdata.cloud.attacker.com"),
"a look-alike suffix must not match"
);
assert!(
!is_laserdata_host("laserdata.com.attacker.com"),
"a look-alike suffix must not match"
);
}
#[test]
fn given_a_connection_string_when_the_host_is_extracted_then_should_strip_scheme_userinfo_and_port()
{
assert_eq!(
host_of("iggy+tcp://user:pwd@starter-123.aws.laserdata.cloud:8090"),
"starter-123.aws.laserdata.cloud",
);
assert_eq!(
host_of("user:pwd@host.laserdata.cloud:8090"),
"host.laserdata.cloud"
);
}
#[test]
fn given_a_laserdata_cloud_host_when_resolving_tls_then_should_attach_tls_and_the_bundled_ca() {
let resolved = resolve_tls("iggy+tcp://u:p@h.laserdata.cloud:8090".to_owned())
.expect("tls resolution should succeed");
assert!(resolved.contains("tls=true"), "{resolved}");
assert!(resolved.contains("tls_ca_file="), "{resolved}");
}
#[test]
fn given_a_non_laserdata_host_when_resolving_tls_then_should_leave_it_untouched() {
assert_eq!(
resolve_tls("iggy+tcp://u:p@127.0.0.1:8090".to_owned())
.expect("tls resolution should succeed"),
"iggy+tcp://u:p@127.0.0.1:8090",
);
}
#[test]
fn given_a_custom_ca_when_resolving_a_local_host_then_should_enable_tls() {
let resolved = resolve_tls_with(
"iggy+tcp://u:p@demo.localhost:8090".to_owned(),
false,
Some("/tmp/local-ca.crt".into()),
)
.expect("local TLS resolution should succeed");
assert_eq!(
resolved,
"iggy+tcp://u:p@demo.localhost:8090?tls=true&tls_ca_file=/tmp/local-ca.crt"
);
}
#[test]
fn given_an_explicit_tls_ca_file_when_resolving_tls_then_should_leave_it_untouched() {
let connection_string =
"iggy+tcp://u:p@h.laserdata.cloud:8090?tls_ca_file=/tmp/my-ca.crt".to_owned();
assert_eq!(
resolve_tls(connection_string.clone()).expect("tls resolution should succeed"),
connection_string,
);
}
#[test]
fn given_a_password_containing_a_slash_when_the_host_is_extracted_then_should_find_the_real_host()
{
assert_eq!(
host_of("iggy+tcp://user:pa/ss@host.laserdata.cloud:8090"),
"host.laserdata.cloud",
);
assert_eq!(
host_of("iggy+tcp://user:a/b/c@host.laserdata.cloud"),
"host.laserdata.cloud",
);
}
#[test]
fn given_a_password_containing_a_slash_when_resolving_tls_then_should_still_attach_tls() {
let resolved = resolve_tls("iggy+tcp://user:pa/ss@h.laserdata.cloud:8090".to_owned())
.expect("tls resolution should succeed");
assert!(
resolved.contains("tls=true"),
"a slash in the password must not silently disable TLS: {resolved}"
);
}
#[test]
fn given_an_at_sign_inside_the_query_when_the_host_is_extracted_then_should_ignore_it() {
assert_eq!(
host_of("iggy+tcp://u:p@h.laserdata.cloud:8090?tls_ca_file=/x@y.crt"),
"h.laserdata.cloud",
);
assert_eq!(
host_of("iggy+tcp://h.laserdata.cloud:8090?note=a@b"),
"h.laserdata.cloud",
);
}
#[test]
fn given_a_bracketed_ipv6_authority_when_the_host_is_extracted_then_should_drop_the_brackets() {
assert_eq!(host_of("iggy+tcp://u:p@[::1]:8090"), "::1");
assert_eq!(
endpoint_of("iggy+tcp://u:p@h.laserdata.cloud:8090?tls=true"),
"h.laserdata.cloud:8090"
);
assert_eq!(endpoint_of("iggy+tcp://u:p/q@[::1]:8090/x"), "[::1]:8090");
}
#[test]
fn given_a_parameter_name_inside_the_password_when_checked_then_should_not_count_as_a_query_param()
{
assert!(!has_query_param(
"iggy+tcp://u:tls_ca_file=x@h.laserdata.cloud:8090",
"tls_ca_file"
));
assert!(has_query_param(
"iggy+tcp://u:p@h.laserdata.cloud:8090?tls_ca_file=/ca.crt",
"tls_ca_file"
));
assert!(has_query_param(
"iggy+tcp://u:p@h.laserdata.cloud:8090?tls=true&tls_ca_file=/ca.crt",
"tls"
));
}
#[test]
fn given_a_password_containing_a_parameter_name_when_resolving_tls_then_should_still_attach_the_ca()
{
let resolved = resolve_tls("iggy+tcp://u:tls_ca_file=x@h.laserdata.cloud:8090".to_owned())
.expect("tls resolution should succeed");
assert!(
resolved.contains("tls=true"),
"credential content must not suppress TLS: {resolved}"
);
assert!(resolved.ends_with(".crt"), "{resolved}");
}
#[test]
fn given_an_opt_out_flag_value_when_read_then_should_only_accept_an_affirmative() {
assert!(flag_value_enabled("1"));
assert!(flag_value_enabled("true"));
assert!(flag_value_enabled("TRUE"));
assert!(flag_value_enabled(" yes "));
assert!(flag_value_enabled("on"));
assert!(!flag_value_enabled("0"), "`0` must not disable TLS");
assert!(!flag_value_enabled("false"), "`false` must not disable TLS");
assert!(
!flag_value_enabled(""),
"an empty value must not disable TLS"
);
assert!(!flag_value_enabled("no"));
}
#[test]
fn given_no_cached_certificate_when_installed_then_should_write_the_bundled_ca() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = install_cert(dir.path()).expect("the certificate installs");
assert_eq!(
std::fs::read(&path).expect("the installed certificate reads"),
PROD_CERT,
);
}
#[test]
fn given_a_tampered_cached_certificate_when_installed_then_should_replace_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let planted = dir.path().join("laserdata.crt");
std::fs::write(&planted, b"-----BEGIN CERTIFICATE-----\nattacker\n")
.expect("the planted certificate writes");
let path = install_cert(dir.path()).expect("the certificate installs");
assert_eq!(path, planted);
assert_eq!(
std::fs::read(&path).expect("the installed certificate reads"),
PROD_CERT,
"a pre-planted trust anchor must be replaced, never trusted"
);
}
#[test]
fn given_an_already_current_certificate_when_installed_then_should_reuse_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let first = install_cert(dir.path()).expect("the certificate installs");
let second = install_cert(dir.path()).expect("the certificate installs again");
assert_eq!(first, second);
assert_eq!(
std::fs::read(&second).expect("the installed certificate reads"),
PROD_CERT,
);
}
#[cfg(unix)]
#[test]
fn given_an_installed_certificate_when_inspected_then_should_be_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let path = install_cert(dir.path()).expect("the certificate installs");
let file_mode = std::fs::metadata(&path)
.expect("the certificate has metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(file_mode, 0o600, "the cached CA must be owner-only");
let dir_mode = std::fs::metadata(dir.path())
.expect("the cache directory has metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(
dir_mode, 0o700,
"the cache directory must not be group or world writable"
);
}
}
#[cfg(test)]
mod resource_naming_tests {
use super::{Laser, ResourceNaming};
fn laser() -> Laser {
Laser::from_client(iggy::prelude::IggyClient::default())
}
#[test]
fn given_a_default_stream_when_naming_a_resource_then_should_scope_it_to_the_stream() {
let laser = laser().with_default_stream("acme");
assert_eq!(laser.resource_naming(), ResourceNaming::Stream);
assert_eq!(laser.resource_name("agent.keys"), "stream:acme/agent.keys");
assert_eq!(laser.resource_stream(), Some("acme"));
}
#[test]
fn given_a_prefixed_name_when_naming_it_then_should_send_it_as_is() {
let laser = laser().with_default_stream("acme");
assert_eq!(
laser.resource_name("stream:other/sessions"),
"stream:other/sessions"
);
}
#[test]
fn given_bare_naming_when_naming_a_resource_then_should_send_the_name_unchanged() {
let laser = laser()
.with_default_stream("acme")
.with_resource_naming(ResourceNaming::Bare);
assert_eq!(laser.resource_name("agent.keys"), "agent.keys");
assert_eq!(laser.resource_stream(), None);
assert_eq!(
laser.local_resource_name("stream:acme/x"),
Some("stream:acme/x")
);
}
#[test]
fn given_no_default_stream_when_naming_a_resource_then_should_send_the_name_unchanged() {
let laser = laser();
assert_eq!(laser.resource_name("agent.keys"), "agent.keys");
assert_eq!(laser.local_resource_name("agent.keys"), Some("agent.keys"));
}
#[test]
fn given_an_empty_name_when_naming_it_then_should_stay_empty_for_validation() {
assert_eq!(laser().with_default_stream("acme").resource_name(""), "");
}
#[test]
fn given_listed_names_when_localized_then_should_strip_the_own_prefix_and_drop_the_rest() {
let laser = laser().with_default_stream("acme");
assert_eq!(
laser.local_resource_name("stream:acme/sessions"),
Some("sessions")
);
assert_eq!(laser.local_resource_name("stream:other/sessions"), None);
assert_eq!(laser.local_resource_name("sessions"), None);
}
#[test]
fn given_an_explicit_stream_when_naming_in_it_then_should_scope_to_that_stream() {
let laser = laser().with_default_stream("acme");
assert_eq!(
laser.resource_name_in(Some("fleet"), "memory"),
"stream:fleet/memory"
);
assert_eq!(laser.resource_name_in(None, "memory"), "memory");
}
}