Files
qdrant/src/settings.rs
Tim Visée 4507a118a2 Add config option to disable snapshot restore from URL (#8628)
* [ai] Add config boolean to disable URL based snapshot restore

* Merge if-statement

* Comment-out config option by default

* [ai] Also block partial snapshots from remote URLs

* [ai] Only run clock consistency test when staging feature is present

* [ai] Add integration test
2026-04-24 16:53:57 +02:00

672 lines
22 KiB
Rust

use std::borrow::Cow;
use std::{env, io};
use api::grpc::transport_channel_pool::{
DEFAULT_CONNECT_TIMEOUT, DEFAULT_GRPC_TIMEOUT, DEFAULT_POOL_SIZE,
};
use collection::operations::validation;
use collection::shards::shard::PeerId;
use common::flags::FeatureFlags;
use config::{Config, ConfigError, Environment, File, FileFormat, Source};
use serde::Deserialize;
use storage::types::StorageConfig;
use validator::{Validate, ValidationError};
use crate::common::audit::AuditConfig;
use crate::common::debugger::DebuggerConfig;
use crate::common::inference::config::InferenceConfig;
use crate::tracing;
const MAX_PEER_ID: u64 = (1 << 53) - 1;
const DEFAULT_CONFIG: &str = include_str!("../config/config.yaml");
#[derive(Debug, Deserialize, Validate, Clone)]
pub struct ServiceConfig {
#[validate(length(min = 1))]
pub host: String,
pub http_port: u16,
pub grpc_port: Option<u16>, // None means that gRPC is disabled
/// If specified, qdrant will serve a separate service for `/metrics` on this port.
/// Separate port is not protected by API keys and dedicated for internal monitoring systems.
/// This port should not be exposed to untrusted networks.
#[serde(default)]
pub metrics_port: Option<u16>,
pub max_request_size_mb: usize,
pub max_workers: Option<usize>,
/// Keep-alive timeout for incoming HTTP connections in seconds.
#[serde(default = "default_http_keep_alive_timeout_sec")]
#[validate(range(min = 1))]
pub http_keep_alive_timeout_sec: u64,
/// Timeout for reading HTTP request data from clients in seconds.
#[serde(default = "default_http_client_request_timeout_sec")]
#[validate(range(min = 1))]
pub http_client_request_timeout_sec: u64,
/// Timeout for client disconnect handling in seconds.
#[serde(default = "default_http_client_disconnect_timeout_sec")]
#[validate(range(min = 1))]
pub http_client_disconnect_timeout_sec: u64,
#[serde(default = "default_cors")]
pub enable_cors: bool,
#[serde(default)]
pub enable_tls: bool,
#[serde(default)]
pub verify_https_client_certificate: bool,
pub api_key: Option<String>,
/// Same as `api_key`, can be used for rolling key rotation.
pub alt_api_key: Option<String>,
pub read_only_api_key: Option<String>,
#[serde(default)]
pub jwt_rbac: Option<bool>,
/// Enforce API key / JWT authentication on the internal (p2p) gRPC API.
///
/// The regular API key is always forwarded on internal gRPC requests, but
/// the receiving side only verifies it when this flag is enabled. This is
/// opt-in to keep rolling upgrades safe: during an upgrade some nodes may
/// run a version that does not attach the key yet, so enabling enforcement
/// before every peer is upgraded would break intra-cluster communication.
#[serde(default)]
pub enforce_internal_auth: Option<bool>,
#[serde(default)]
pub hide_jwt_dashboard: Option<bool>,
/// Directory where static files are served from.
/// For example, the Web-UI should be placed here.
#[serde(default)]
pub static_content_dir: Option<String>,
/// If serving of the static content is enabled.
/// This includes the Web-UI. True by default.
#[serde(default)]
pub enable_static_content: Option<bool>,
/// How much time is considered too long for a query to execute.
pub slow_query_secs: Option<f32>,
/// Whether to enable reporting of measured hardware utilization in API responses.
#[serde(default)]
pub hardware_reporting: Option<bool>,
/// Global prefix for metrics.
#[serde(default)]
#[validate(custom(function = validate_metrics_prefix))]
pub metrics_prefix: Option<String>,
/// Whether to allow snapshot recovery from remote URLs (http/https).
/// If disabled, snapshot recovery will only work with local files and uploads.
/// Disabling this can mitigate SSRF risks in environments where the Qdrant node
/// has access to internal resources that should not be reachable by users.
#[serde(default = "default_snapshot_url_recovery")]
pub enable_snapshot_url_recovery: bool,
}
impl ServiceConfig {
pub fn hardware_reporting(&self) -> bool {
self.hardware_reporting.unwrap_or_default()
}
}
#[derive(Debug, Deserialize, Clone, Default, Validate)]
pub struct ClusterConfig {
pub enabled: bool, // disabled by default
#[serde(default)]
#[validate(range(min = 1, max = MAX_PEER_ID))]
pub peer_id: Option<PeerId>,
#[serde(default = "default_timeout_ms")]
#[validate(range(min = 1))]
pub grpc_timeout_ms: u64,
#[serde(default = "default_connection_timeout_ms")]
#[validate(range(min = 1))]
pub connection_timeout_ms: u64,
#[serde(default)]
#[validate(nested)]
pub p2p: P2pConfig,
#[serde(default)]
#[validate(nested)]
pub consensus: ConsensusConfig,
#[serde(default)]
pub resharding_enabled: bool, // disabled by default
}
#[derive(Debug, Deserialize, Clone, Validate)]
pub struct P2pConfig {
#[serde(default)]
pub port: Option<u16>,
#[serde(default = "default_connection_pool_size")]
#[validate(range(min = 1))]
pub connection_pool_size: usize,
#[serde(default)]
pub enable_tls: bool,
}
impl Default for P2pConfig {
fn default() -> Self {
P2pConfig {
port: None,
connection_pool_size: default_connection_pool_size(),
enable_tls: false,
}
}
}
#[derive(Debug, Deserialize, Clone, Validate)]
pub struct ConsensusConfig {
#[serde(default = "default_max_message_queue_size")]
pub max_message_queue_size: usize, // controls the back-pressure at the Raft level
#[serde(default = "default_tick_period_ms")]
#[validate(range(min = 1))]
pub tick_period_ms: u64,
#[serde(default = "default_bootstrap_timeout_sec")]
#[validate(range(min = 1))]
pub bootstrap_timeout_sec: u64,
#[validate(range(min = 1))]
#[serde(default = "default_message_timeout_tics")]
pub message_timeout_ticks: u64,
/// Compact WAL when it grows to enough applied entries
#[serde(default = "default_compact_wal_entries")]
pub compact_wal_entries: u64,
}
impl Default for ConsensusConfig {
fn default() -> Self {
ConsensusConfig {
max_message_queue_size: default_max_message_queue_size(),
tick_period_ms: default_tick_period_ms(),
bootstrap_timeout_sec: default_bootstrap_timeout_sec(),
message_timeout_ticks: default_message_timeout_tics(),
compact_wal_entries: default_compact_wal_entries(),
}
}
}
#[derive(Debug, Deserialize, Clone, Validate)]
pub struct TlsConfig {
pub cert: String,
pub key: String,
pub ca_cert: Option<String>,
#[serde(default = "default_tls_cert_ttl")]
#[validate(range(min = 1))]
pub cert_ttl: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Validate)]
pub struct GpuConfig {
/// Enable GPU indexing.
#[serde(default)]
pub indexing: bool,
/// Force half precision for `f32` values while indexing.
/// `f16` conversion will take place only inside GPU memory and won't affect storage type.
#[serde(default)]
pub force_half_precision: bool,
/// Used vulkan "groups" of GPU. In other words, how many parallel points can be indexed by GPU.
/// Optimal value might depend on the GPU model.
/// Proportional, but doesn't necessary equal to the physical number of warps.
/// Do not change this value unless you know what you are doing.
/// Default: 512
#[serde(default)]
#[validate(range(min = 1))]
pub groups_count: Option<usize>,
/// Filter for GPU devices by hardware name. Case insensitive.
/// Comma-separated list of substrings to match against the gpu device name.
/// Example: "nvidia"
/// Default: "" - all devices are accepted.
#[serde(default)]
pub device_filter: String,
/// List of explicit GPU devices to use.
/// If host has multiple GPUs, this option allows to select specific devices
/// by their index in the list of found devices.
/// If `device_filter` is set, indexes are applied after filtering.
/// By default, all devices are accepted.
#[serde(default)]
pub devices: Option<Vec<usize>>,
/// How many parallel indexing processes are allowed to run.
/// Default: 1
#[serde(default)]
pub parallel_indexes: Option<usize>,
/// Allow to use integrated GPUs.
/// Default: false
#[serde(default)]
pub allow_integrated: bool,
/// Allow to use emulated GPUs like LLVMpipe. Useful for CI.
/// Default: false
#[serde(default)]
pub allow_emulated: bool,
}
#[derive(Debug, Deserialize, Clone, Validate)]
pub struct Settings {
#[serde(default)]
pub log_level: Option<String>,
#[serde(default)]
pub logger: tracing::LoggerConfig,
#[validate(nested)]
pub storage: StorageConfig,
#[validate(nested)]
pub service: ServiceConfig,
#[serde(default)]
#[validate(nested)]
pub cluster: ClusterConfig,
#[serde(default = "default_telemetry_disabled")]
pub telemetry_disabled: bool,
#[validate(nested)]
pub tls: Option<TlsConfig>,
#[serde(default)]
pub debugger: DebuggerConfig,
/// A list of messages for errors that happened during loading the configuration. We collect
/// them and store them here while loading because then our logger is not configured yet.
/// We therefore need to log these messages later, after the logger is ready.
#[serde(default, skip)]
pub load_errors: Vec<LogMsg>,
#[serde(default)]
pub inference: Option<InferenceConfig>,
#[serde(default)]
#[validate(nested)]
pub gpu: Option<GpuConfig>,
#[serde(default)]
pub feature_flags: FeatureFlags,
/// Audit logging configuration.
#[serde(default)]
pub audit: Option<AuditConfig>,
}
impl Settings {
pub fn new(custom_config_path: Option<String>) -> Result<Self, ConfigError> {
let mut load_errors = vec![];
let config_exists = |path| File::with_name(path).collect().is_ok();
// Check if custom config file exists, report error if not
if let Some(path) = &custom_config_path
&& !config_exists(path)
{
load_errors.push(LogMsg::Error(format!(
"Config file via --config-path is not found: {path}"
)));
}
let env = env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
let config_path_env = format!("config/{env}");
// Report error if main or env config files exist, report warning if not
// Check if main and env configuration file
load_errors.extend(
["config/config", &config_path_env]
.into_iter()
.filter(|path| !config_exists(path))
.map(|path| LogMsg::Warn(format!("Config file not found: {path}"))),
);
// Configuration builder: define different levels of configuration files
let mut config = Config::builder()
// Start with compile-time base config
.add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml))
// Merge main config: config/config
.add_source(File::with_name("config/config").required(false))
// Merge env config: config/{env}
// Uses RUN_MODE, defaults to 'development'
.add_source(File::with_name(&config_path_env).required(false))
// Merge local config, not tracked in git: config/local
.add_source(File::with_name("config/local").required(false));
#[cfg(feature = "deb")]
{
// Read config, installed with deb package
config = config.add_source(File::with_name("/etc/qdrant/config").required(false));
}
// Merge user provided config with --config-path
if let Some(path) = custom_config_path {
config = config.add_source(File::with_name(&path).required(false));
}
// Merge environment settings
// E.g.: `QDRANT_DEBUG=1 ./target/app` would set `debug=true`
config = config.add_source(Environment::with_prefix("QDRANT").separator("__"));
// Build and merge config and deserialize into Settings, attach any load errors we had
let mut settings: Settings = config.build()?.try_deserialize()?;
settings.load_errors.extend(load_errors);
Ok(settings)
}
pub fn tls(&self) -> io::Result<&TlsConfig> {
self.tls
.as_ref()
.ok_or_else(Self::tls_config_is_undefined_error)
}
pub fn tls_config_is_undefined_error() -> io::Error {
io::Error::other("TLS config is not defined in the Qdrant config file")
}
pub fn validate_and_warn(&self) {
//
// JWT RBAC
//
// Using HMAC-SHA256, recommended secret size is 32 bytes
const JWT_RECOMMENDED_SECRET_LENGTH: usize = 256 / 8;
let all_keys_are_empty = self
.service
.api_key
.as_deref()
.unwrap_or_default()
.is_empty()
&& self
.service
.alt_api_key
.as_deref()
.unwrap_or_default()
.is_empty();
let min_length = [
self.service.api_key.as_ref(),
self.service.alt_api_key.as_ref(),
]
.into_iter()
.flatten()
.map(|key| key.len())
.min()
.unwrap_or_default();
let any_api_key_is_short = min_length < JWT_RECOMMENDED_SECRET_LENGTH;
// Log if JWT RBAC is enabled but no API key is set
if self.service.jwt_rbac.unwrap_or_default() {
if all_keys_are_empty {
log::warn!("JWT RBAC configured but no API key set, JWT RBAC is not enabled")
// Log if JWT RAC is enabled, API key is set but smaller than recommended size for JWT secret
} else if any_api_key_is_short {
log::warn!(
"It is highly recommended to use an API key of {JWT_RECOMMENDED_SECRET_LENGTH} bytes when JWT RBAC is enabled",
)
}
}
// Print any load error messages we had
self.load_errors.iter().for_each(LogMsg::log);
if let Err(ref errs) = self.validate() {
validation::warn_validation_errors("Settings configuration file", errs);
}
}
}
/// Returns the number of maximum actix workers.
pub fn max_web_workers(settings: &Settings) -> usize {
match settings.service.max_workers {
Some(0) => {
let num_cpu = common::cpu::get_num_cpus();
std::cmp::max(1, num_cpu - 1)
}
Some(max_workers) => max_workers,
None => settings.storage.performance.max_search_threads,
}
}
#[derive(Clone, Debug)]
pub enum LogMsg {
Warn(String),
Error(String),
}
impl LogMsg {
fn log(&self) {
match self {
Self::Warn(msg) => log::warn!("{msg}"),
Self::Error(msg) => log::error!("{msg}"),
}
}
}
const fn default_telemetry_disabled() -> bool {
false
}
const fn default_cors() -> bool {
true
}
const fn default_snapshot_url_recovery() -> bool {
true
}
const fn default_http_keep_alive_timeout_sec() -> u64 {
5
}
const fn default_http_client_request_timeout_sec() -> u64 {
5
}
const fn default_http_client_disconnect_timeout_sec() -> u64 {
5
}
const fn default_timeout_ms() -> u64 {
DEFAULT_GRPC_TIMEOUT.as_millis() as u64
}
const fn default_connection_timeout_ms() -> u64 {
DEFAULT_CONNECT_TIMEOUT.as_millis() as u64
}
const fn default_tick_period_ms() -> u64 {
100
}
// Should not be less than `DEFAULT_META_OP_WAIT` as bootstrapping perform sync. consensus meta operations.
const fn default_bootstrap_timeout_sec() -> u64 {
15
}
const fn default_max_message_queue_size() -> usize {
100
}
const fn default_connection_pool_size() -> usize {
DEFAULT_POOL_SIZE
}
const fn default_message_timeout_tics() -> u64 {
10
}
const fn default_compact_wal_entries() -> u64 {
128
}
#[allow(clippy::unnecessary_wraps)] // Used as serde default
const fn default_tls_cert_ttl() -> Option<u64> {
// Default one hour
Some(3600)
}
/// Custom validation function for metrics prefixes.
fn validate_metrics_prefix(prefix: &str) -> Result<(), ValidationError> {
// Prefix is not required
if prefix.is_empty() {
return Ok(());
}
// Only allow alphanumeric characters or '_'
if !prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(
ValidationError::new("invalid_metrics_prefix").with_message(Cow::Borrowed(
"Metrics prefix must be of all alphanumeric characters, with an exception for '_'",
)),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::io::Write;
use fs_err as fs;
use sealed_test::prelude::*;
use super::*;
/// Ensure we can successfully deserialize into [`Settings`] with just the default configuration.
#[test]
fn test_default_config() {
let config = Config::builder()
.add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml))
.build()
.expect("failed to build default config")
.try_deserialize::<Settings>()
.expect("failed to deserialize default config");
assert_eq!(
config.service.http_keep_alive_timeout_sec,
default_http_keep_alive_timeout_sec()
);
assert_eq!(
config.service.http_client_request_timeout_sec,
default_http_client_request_timeout_sec()
);
assert_eq!(
config.service.http_client_disconnect_timeout_sec,
default_http_client_disconnect_timeout_sec()
);
config
.validate()
.expect("failed to validate default config");
}
#[expect(
clippy::disallowed_methods,
reason = "#[sealed_test] uses std::fs::copy"
)]
#[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")]
#[sealed_test(files = ["config/config.yaml", "config/development.yaml"])]
fn test_runtime_development_config() {
unsafe { env::set_var("RUN_MODE", "development") };
// `sealed_test` copies files into the same directory as the test runs in.
// We need them in a subdirectory.
fs::create_dir("config").expect("failed to create `config` subdirectory.");
fs::copy("config.yaml", "config/config.yaml").expect("failed to copy `config.yaml`.");
fs::copy("development.yaml", "config/development.yaml")
.expect("failed to copy `development.yaml`.");
// Read config
let config = Settings::new(None).expect("failed to load development config at runtime");
// Validate
config
.validate()
.expect("failed to validate development config at runtime");
assert!(config.load_errors.is_empty(), "must not have load errors")
}
#[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")]
#[sealed_test]
fn test_no_config_files() {
let non_existing_config_path = "config/non_existing_config".to_string();
// Read config
let config = Settings::new(Some(non_existing_config_path))
.expect("failed to load with non-existing runtime config");
// Validate
config
.validate()
.expect("failed to validate with non-existing runtime config");
assert!(!config.load_errors.is_empty(), "must have load errors")
}
#[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")]
#[sealed_test]
fn test_custom_config() {
let path = "config/custom.yaml";
// Create custom config file
{
fs::create_dir("config").unwrap();
let mut custom = fs::File::create(path).unwrap();
write!(&mut custom, "service:\n http_port: 9999").unwrap();
custom.flush().unwrap();
}
// Load settings with custom config
let config = Settings::new(Some(path.into())).unwrap();
// Ensure our custom config is the most important
assert_eq!(config.service.http_port, 9999);
assert_eq!(
config.service.http_keep_alive_timeout_sec,
default_http_keep_alive_timeout_sec()
);
assert_eq!(
config.service.http_client_request_timeout_sec,
default_http_client_request_timeout_sec()
);
assert_eq!(
config.service.http_client_disconnect_timeout_sec,
default_http_client_disconnect_timeout_sec()
);
}
#[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")]
#[sealed_test]
fn test_custom_http_transport_config() {
let path = "config/custom_http_transport.yaml";
{
fs::create_dir("config").unwrap();
let mut custom = fs::File::create(path).unwrap();
write!(
&mut custom,
"service:\n http_keep_alive_timeout_sec: 120\n http_client_request_timeout_sec: 45\n http_client_disconnect_timeout_sec: 60"
)
.unwrap();
custom.flush().unwrap();
}
let config = Settings::new(Some(path.into())).unwrap();
config
.validate()
.expect("custom HTTP transport timeouts must pass validation");
assert_eq!(config.service.http_keep_alive_timeout_sec, 120);
assert_eq!(config.service.http_client_request_timeout_sec, 45);
assert_eq!(config.service.http_client_disconnect_timeout_sec, 60);
}
#[expect(clippy::disallowed_types, reason = "#[sealed_test] uses std::fs::File")]
#[sealed_test]
fn test_invalid_http_transport_config() {
let path = "config/invalid_http_transport.yaml";
{
fs::create_dir("config").unwrap();
let mut custom = fs::File::create(path).unwrap();
write!(
&mut custom,
"service:\n http_keep_alive_timeout_sec: 0\n http_client_request_timeout_sec: 0\n http_client_disconnect_timeout_sec: 0"
)
.unwrap();
custom.flush().unwrap();
}
let config = Settings::new(Some(path.into())).unwrap();
assert!(
config.validate().is_err(),
"zero timeout values must fail validation"
);
}
}