1use axum::{
4 extract::{Extension, Json, Path, State},
5 response::Json as JsonResponse,
6};
7use serde::Deserialize;
8use serde_json;
9
10use crate::http::{error::HttpError, state::AppState};
11use crate::storage::{
12 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
13};
14
15#[utoipa::path(
16 get,
17 path = "/v1/config",
18 tag = "Config",
19 responses(
20 (status = 200, body = serde_json::Value, description = "Current configuration"),
21 (status = 500, description = "Internal error")
22 )
23)]
24pub async fn get<
26 S: StorageClient
27 + StorageSchemaContextOps
28 + StorageSyncContextOps
29 + StorageOperationContextOps
30 + Clone
31 + Send
32 + Sync
33 + 'static,
34>(
35 State(state): State<AppState<S>>,
36 Extension(session_id): Extension<i64>,
37) -> Result<JsonResponse<serde_json::Value>, HttpError> {
38 super::authz::require_admin_session(&state, session_id)?;
39 let config = state.server.get_config();
40
41 Ok(JsonResponse(serde_json::json!({
42 "database": {
43 "host": config.common.database.host,
44 "port": config.common.database.port,
45 "storage_path": config.common.database.storage_path,
46 "max_sessions": config.common.database.max_sessions,
47 },
48 "transaction": {
49 "default_timeout": config.common.transaction.default_timeout,
50 "max_concurrent_transactions": config.common.transaction.max_concurrent_transactions,
51 "auto_commit": config.common.transaction.auto_commit,
52 },
53 "log": {
54 "level": config.common.log.level,
55 "dir": config.common.log.dir,
56 "basename": config.common.log.basename,
57 "max_file_size_mb": config.common.log.max_file_size_mb,
58 "max_files": config.common.log.max_files,
59 "stdout": config.common.log.stdout,
60 "json_format": config.common.log.json_format,
61 },
62 "auth": {
63 "enable_authorize": config.server.auth.enable_authorize,
64 "failed_login_attempts": config.server.auth.failed_login_attempts,
65 "session_idle_timeout_secs": config.server.auth.session_idle_timeout_secs,
66 "force_change_default_password": config.server.auth.force_change_default_password,
67 "default_username": config.server.auth.default_username,
68 "bcrypt_cost": config.server.auth.bcrypt_cost,
69 },
70 "bootstrap": {
71 "auto_create_default_space": config.server.bootstrap.auto_create_default_space,
72 "default_space_name": config.server.bootstrap.default_space_name,
73 "single_user_mode": config.server.bootstrap.single_user_mode,
74 },
75 "optimizer": {
76 "max_iteration_rounds": config.common.optimizer.max_iteration_rounds,
77 "max_exploration_rounds": config.common.optimizer.max_exploration_rounds,
78 "enable_cost_model": config.common.optimizer.enable_cost_model,
79 "enable_multi_plan": config.common.optimizer.enable_multi_plan,
80 "enable_property_pruning": config.common.optimizer.enable_property_pruning,
81 "enable_adaptive_iteration": config.common.optimizer.enable_adaptive_iteration,
82 "stable_threshold": config.common.optimizer.stable_threshold,
83 "min_iteration_rounds": config.common.optimizer.min_iteration_rounds,
84 "statistics_sample_limit": config.common.optimizer.statistics_sample_limit,
85 "statistics_min_epoch_delta": config.common.optimizer.statistics_min_epoch_delta,
86 "storage_cost_profile": config.common.optimizer.storage_cost_profile,
87 "space_cost_profiles": config.common.optimizer.space_cost_profiles,
88 },
89 "monitoring": {
90 "enabled": config.common.monitoring.enabled,
91 "memory_cache_size": config.common.monitoring.memory_cache_size,
92 "slow_query_threshold_ms": config.common.monitoring.slow_query_threshold_ms,
93 },
94 })))
95}
96
97#[utoipa::path(
98 put,
99 path = "/v1/config",
100 tag = "Config",
101 request_body = serde_json::Value,
102 responses(
103 (status = 200, body = serde_json::Value, description = "Configuration update receipt"),
104 (status = 500, description = "Internal error")
105 )
106)]
107pub async fn update<
109 S: StorageClient
110 + StorageSchemaContextOps
111 + StorageSyncContextOps
112 + StorageOperationContextOps
113 + Clone
114 + Send
115 + Sync
116 + 'static,
117>(
118 State(state): State<AppState<S>>,
119 Extension(session_id): Extension<i64>,
120 Json(request): Json<serde_json::Value>,
121) -> Result<JsonResponse<serde_json::Value>, HttpError> {
122 super::authz::require_admin_session(&state, session_id)?;
123 let store = state.server.config_store();
124 let config_path = state.server.get_config_path();
125 let mut updated = Vec::new();
126 let mut requires_restart = Vec::new();
127 let mut persisted = true;
128
129 if let Some(sections) = request.as_object() {
130 for (section, values) in sections {
131 if let Some(values_obj) = values.as_object() {
132 for (key, value) in values_obj {
133 let full_key = format!("{section}.{key}");
134 match apply_config_update(&store, config_path.as_deref(), section, key, value) {
135 Ok((restart, wrote)) => {
136 updated.push(full_key);
137 if restart {
138 requires_restart.push(format!("{section}.{key}"));
139 }
140 persisted = persisted && wrote;
141 }
142 Err(e) => return Err(HttpError::bad_request(e)),
143 }
144 }
145 }
146 }
147 }
148
149 Ok(JsonResponse(serde_json::json!({
150 "updated": updated,
151 "requires_restart": requires_restart,
152 "persisted": persisted,
153 "message": if persisted {
154 "Configuration updated and persisted; restart-required keys take effect after restart"
155 } else {
156 "Configuration updated in memory only (no config file retained); restart to reload from file"
157 },
158 })))
159}
160
161#[utoipa::path(
162 get,
163 path = "/v1/config/{section}/{key}",
164 tag = "Config",
165 params(
166 ("section" = String, Path, description = "Configuration section"),
167 ("key" = String, Path, description = "Configuration key")
168 ),
169 responses(
170 (status = 200, body = serde_json::Value, description = "Configuration item"),
171 (status = 500, description = "Internal error")
172 )
173)]
174pub async fn get_key<
176 S: StorageClient
177 + StorageSchemaContextOps
178 + StorageSyncContextOps
179 + StorageOperationContextOps
180 + Clone
181 + Send
182 + Sync
183 + 'static,
184>(
185 State(state): State<AppState<S>>,
186 Extension(session_id): Extension<i64>,
187 Path((section, key)): Path<(String, String)>,
188) -> Result<JsonResponse<serde_json::Value>, HttpError> {
189 super::authz::require_admin_session(&state, session_id)?;
190 let config = state.server.get_config();
191 let value = get_config_value(&config, §ion, &key);
192
193 Ok(JsonResponse(serde_json::json!({
194 "section": section,
195 "key": key,
196 "value": value,
197 })))
198}
199
200#[utoipa::path(
201 put,
202 path = "/v1/config/{section}/{key}",
203 tag = "Config",
204 params(
205 ("section" = String, Path, description = "Configuration section"),
206 ("key" = String, Path, description = "Configuration key")
207 ),
208 request_body = UpdateConfigRequest,
209 responses(
210 (status = 200, body = serde_json::Value, description = "Configuration item updated"),
211 (status = 500, description = "Internal error")
212 )
213)]
214pub async fn update_key<
216 S: StorageClient
217 + StorageSchemaContextOps
218 + StorageSyncContextOps
219 + StorageOperationContextOps
220 + Clone
221 + Send
222 + Sync
223 + 'static,
224>(
225 State(state): State<AppState<S>>,
226 Extension(session_id): Extension<i64>,
227 Path((section, key)): Path<(String, String)>,
228 Json(request): Json<UpdateConfigRequest>,
229) -> Result<JsonResponse<serde_json::Value>, HttpError> {
230 super::authz::require_admin_session(&state, session_id)?;
231 let store = state.server.config_store();
232 let config_path = state.server.get_config_path();
233 let (requires_restart, persisted) = apply_config_update(
234 &store,
235 config_path.as_deref(),
236 §ion,
237 &key,
238 &request.value,
239 )
240 .map_err(HttpError::bad_request)?;
241
242 Ok(JsonResponse(serde_json::json!({
243 "section": section,
244 "key": key,
245 "value": request.value,
246 "requires_restart": requires_restart,
247 "persisted": persisted,
248 "message": if requires_restart {
249 "Configuration item updated; restart required to take effect"
250 } else if persisted {
251 "Configuration item updated and persisted"
252 } else {
253 "Configuration item updated in memory only (no config file retained)"
254 },
255 })))
256}
257
258#[utoipa::path(
259 delete,
260 path = "/v1/config/{section}/{key}",
261 tag = "Config",
262 params(
263 ("section" = String, Path, description = "Configuration section"),
264 ("key" = String, Path, description = "Configuration key")
265 ),
266 responses(
267 (status = 200, body = serde_json::Value, description = "Configuration reset to default"),
268 (status = 500, description = "Internal error")
269 )
270)]
271pub async fn reset_key<
273 S: StorageClient
274 + StorageSchemaContextOps
275 + StorageSyncContextOps
276 + StorageOperationContextOps
277 + Clone
278 + Send
279 + Sync
280 + 'static,
281>(
282 State(state): State<AppState<S>>,
283 Extension(session_id): Extension<i64>,
284 Path((section, key)): Path<(String, String)>,
285) -> Result<JsonResponse<serde_json::Value>, HttpError> {
286 super::authz::require_admin_session(&state, session_id)?;
287 let default_config = crate::config::Config::default();
288 let default_value = get_config_value(&default_config, §ion, &key);
289 let store = state.server.config_store();
290 let config_path = state.server.get_config_path();
291 let (requires_restart, persisted) = apply_config_update(
292 &store,
293 config_path.as_deref(),
294 §ion,
295 &key,
296 &default_value,
297 )
298 .map_err(HttpError::bad_request)?;
299
300 Ok(JsonResponse(serde_json::json!({
301 "section": section,
302 "key": key,
303 "value": default_value,
304 "requires_restart": requires_restart,
305 "persisted": persisted,
306 "message": "Configuration reset to default value",
307 })))
308}
309
310#[derive(Debug, Deserialize, utoipa::ToSchema)]
312pub struct UpdateConfigRequest {
313 pub value: serde_json::Value,
314}
315
316pub(crate) fn get_config_value(
318 config: &crate::config::Config,
319 section: &str,
320 key: &str,
321) -> serde_json::Value {
322 match section {
323 "database" => match key {
324 "host" => serde_json::json!(config.common.database.host),
325 "port" => serde_json::json!(config.common.database.port),
326 "storage_path" => serde_json::json!(config.common.database.storage_path),
327 "max_sessions" => serde_json::json!(config.common.database.max_sessions),
328 _ => serde_json::Value::Null,
329 },
330 "transaction" => match key {
331 "default_timeout" => serde_json::json!(config.common.transaction.default_timeout),
332 "max_concurrent_transactions" => {
333 serde_json::json!(config.common.transaction.max_concurrent_transactions)
334 }
335 "auto_commit" => serde_json::json!(config.common.transaction.auto_commit),
336 _ => serde_json::Value::Null,
337 },
338 "log" => match key {
339 "level" => serde_json::json!(config.common.log.level),
340 "dir" => serde_json::json!(config.common.log.dir),
341 "basename" => serde_json::json!(config.common.log.basename),
342 "max_file_size_mb" => serde_json::json!(config.common.log.max_file_size_mb),
343 "max_files" => serde_json::json!(config.common.log.max_files),
344 "stdout" => serde_json::json!(config.common.log.stdout),
345 "json_format" => serde_json::json!(config.common.log.json_format),
346 _ => serde_json::Value::Null,
347 },
348 "auth" => match key {
349 "enable_authorize" => serde_json::json!(config.server.auth.enable_authorize),
350 "failed_login_attempts" => serde_json::json!(config.server.auth.failed_login_attempts),
351 "session_idle_timeout_secs" => {
352 serde_json::json!(config.server.auth.session_idle_timeout_secs)
353 }
354 "force_change_default_password" => {
355 serde_json::json!(config.server.auth.force_change_default_password)
356 }
357 "default_username" => serde_json::json!(config.server.auth.default_username),
358 "bcrypt_cost" => serde_json::json!(config.server.auth.bcrypt_cost),
359 _ => serde_json::Value::Null,
360 },
361 "bootstrap" => match key {
362 "auto_create_default_space" => {
363 serde_json::json!(config.server.bootstrap.auto_create_default_space)
364 }
365 "default_space_name" => serde_json::json!(config.server.bootstrap.default_space_name),
366 "single_user_mode" => serde_json::json!(config.server.bootstrap.single_user_mode),
367 _ => serde_json::Value::Null,
368 },
369 "http" => match key {
370 "cors_enabled" => serde_json::json!(config.server.http.cors_enabled),
371 "cors_allowed_origins" => {
372 serde_json::json!(config.server.http.cors_allowed_origins)
373 }
374 _ => serde_json::Value::Null,
375 },
376 "security" => match key {
377 "password_min_length" => {
378 serde_json::json!(config.server.security.password_policy.min_length)
379 }
380 "password_require_uppercase" => {
381 serde_json::json!(config.server.security.password_policy.require_uppercase)
382 }
383 "password_require_lowercase" => {
384 serde_json::json!(config.server.security.password_policy.require_lowercase)
385 }
386 "password_require_digit" => {
387 serde_json::json!(config.server.security.password_policy.require_digit)
388 }
389 "password_require_special" => {
390 serde_json::json!(config.server.security.password_policy.require_special)
391 }
392 "password_max_age_days" => {
393 serde_json::json!(config.server.security.password_policy.max_age_days)
394 }
395 "password_history_size" => {
396 serde_json::json!(config.server.security.password_policy.history_size)
397 }
398 _ => serde_json::Value::Null,
399 },
400 "optimizer" => match key {
401 "max_iteration_rounds" => {
402 serde_json::json!(config.common.optimizer.max_iteration_rounds)
403 }
404 "max_exploration_rounds" => {
405 serde_json::json!(config.common.optimizer.max_exploration_rounds)
406 }
407 "enable_cost_model" => serde_json::json!(config.common.optimizer.enable_cost_model),
408 "enable_multi_plan" => serde_json::json!(config.common.optimizer.enable_multi_plan),
409 "enable_property_pruning" => {
410 serde_json::json!(config.common.optimizer.enable_property_pruning)
411 }
412 "enable_adaptive_iteration" => {
413 serde_json::json!(config.common.optimizer.enable_adaptive_iteration)
414 }
415 "stable_threshold" => serde_json::json!(config.common.optimizer.stable_threshold),
416 "min_iteration_rounds" => {
417 serde_json::json!(config.common.optimizer.min_iteration_rounds)
418 }
419 "statistics_sample_limit" => {
420 serde_json::json!(config.common.optimizer.statistics_sample_limit)
421 }
422 "statistics_min_epoch_delta" => {
423 serde_json::json!(config.common.optimizer.statistics_min_epoch_delta)
424 }
425 "storage_cost_profile" => {
426 serde_json::json!(config.common.optimizer.storage_cost_profile)
427 }
428 "space_cost_profiles" => {
429 serde_json::json!(config.common.optimizer.space_cost_profiles)
430 }
431 _ => serde_json::Value::Null,
432 },
433 "monitoring" => match key {
434 "enabled" => serde_json::json!(config.common.monitoring.enabled),
435 "memory_cache_size" => serde_json::json!(config.common.monitoring.memory_cache_size),
436 "slow_query_threshold_ms" => {
437 serde_json::json!(config.common.monitoring.slow_query_threshold_ms)
438 }
439 _ => serde_json::Value::Null,
440 },
441 "migration" => match key {
442 "batch_size" => serde_json::json!(config.common.migration.batch_size),
443 "checkpoint_dir" => serde_json::json!(config.common.migration.checkpoint_dir),
444 "backup_dir" => serde_json::json!(config.common.migration.backup_dir),
445 "lock_path" => serde_json::json!(config.common.migration.lock_path),
446 "drain_timeout_ms" => serde_json::json!(config.common.migration.drain_timeout_ms),
447 "min_free_bytes" => serde_json::json!(config.common.migration.min_free_bytes),
448 _ => serde_json::Value::Null,
449 },
450 _ => serde_json::Value::Null,
451 }
452}
453
454fn is_restart_required(section: &str, key: &str) -> bool {
462 match section {
463 "database" => matches!(key, "host" | "port" | "storage_path" | "max_sessions"),
464 "transaction" => true,
465 "log" => true,
466 "auth" => key != "bcrypt_cost",
467 "bootstrap" => true,
468 "http" => true,
469 "security" => true,
470 "optimizer" => true,
471 "monitoring" => true,
472 "migration" => false,
473 _ => false,
474 }
475}
476
477fn parse_value<T>(key_desc: &str, value: &serde_json::Value) -> Result<T, String>
478where
479 T: for<'de> serde::Deserialize<'de>,
480{
481 serde_json::from_value(value.clone())
482 .map_err(|e| format!("invalid value for '{key_desc}': {e}"))
483}
484
485pub(crate) fn set_config_value(
490 config: &mut crate::config::Config,
491 section: &str,
492 key: &str,
493 value: &serde_json::Value,
494) -> Result<bool, String> {
495 let full_key = format!("{section}.{key}");
496 match section {
497 "database" => match key {
498 "host" => config.common.database.host = parse_value(&full_key, value)?,
499 "port" => config.common.database.port = parse_value(&full_key, value)?,
500 "storage_path" => config.common.database.storage_path = parse_value(&full_key, value)?,
501 "max_sessions" => config.common.database.max_sessions = parse_value(&full_key, value)?,
502 _ => return Err(format!("unknown configuration key '{full_key}'")),
503 },
504 "transaction" => match key {
505 "default_timeout" => {
506 config.common.transaction.default_timeout = parse_value(&full_key, value)?
507 }
508 "max_concurrent_transactions" => {
509 config.common.transaction.max_concurrent_transactions =
510 parse_value(&full_key, value)?
511 }
512 "auto_commit" => config.common.transaction.auto_commit = parse_value(&full_key, value)?,
513 _ => return Err(format!("unknown configuration key '{full_key}'")),
514 },
515 "log" => match key {
516 "level" => config.common.log.level = parse_value(&full_key, value)?,
517 "dir" => config.common.log.dir = parse_value(&full_key, value)?,
518 "basename" => config.common.log.basename = parse_value(&full_key, value)?,
519 "max_file_size_mb" => {
520 config.common.log.max_file_size_mb = parse_value(&full_key, value)?
521 }
522 "max_files" => config.common.log.max_files = parse_value(&full_key, value)?,
523 "stdout" => config.common.log.stdout = parse_value(&full_key, value)?,
524 "json_format" => config.common.log.json_format = parse_value(&full_key, value)?,
525 _ => return Err(format!("unknown configuration key '{full_key}'")),
526 },
527 "auth" => match key {
528 "enable_authorize" => {
529 config.server.auth.enable_authorize = parse_value(&full_key, value)?
530 }
531 "failed_login_attempts" => {
532 config.server.auth.failed_login_attempts = parse_value(&full_key, value)?
533 }
534 "session_idle_timeout_secs" => {
535 config.server.auth.session_idle_timeout_secs = parse_value(&full_key, value)?
536 }
537 "force_change_default_password" => {
538 config.server.auth.force_change_default_password = parse_value(&full_key, value)?
539 }
540 "default_username" => {
541 config.server.auth.default_username = parse_value(&full_key, value)?
542 }
543 "bcrypt_cost" => {
544 let cost: u32 = parse_value(&full_key, value)?;
545 if !(4..=12).contains(&cost) {
546 return Err(format!(
547 "invalid value for '{full_key}': out of range 4..=12"
548 ));
549 }
550 config.server.auth.bcrypt_cost = cost;
551 linkrs_core::types::set_bcrypt_cost(cost);
552 }
553 _ => return Err(format!("unknown configuration key '{full_key}'")),
554 },
555 "bootstrap" => match key {
556 "auto_create_default_space" => {
557 config.server.bootstrap.auto_create_default_space = parse_value(&full_key, value)?
558 }
559 "default_space_name" => {
560 config.server.bootstrap.default_space_name = parse_value(&full_key, value)?
561 }
562 "single_user_mode" => {
563 config.server.bootstrap.single_user_mode = parse_value(&full_key, value)?
564 }
565 _ => return Err(format!("unknown configuration key '{full_key}'")),
566 },
567 "http" => match key {
568 "cors_enabled" => config.server.http.cors_enabled = parse_value(&full_key, value)?,
569 "cors_allowed_origins" => {
570 config.server.http.cors_allowed_origins = parse_value(&full_key, value)?;
571 config
572 .server
573 .http
574 .validate()
575 .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
576 }
577 _ => return Err(format!("unknown configuration key '{full_key}'")),
578 },
579 "security" => match key {
580 "password_min_length" => {
581 config.server.security.password_policy.min_length = parse_value(&full_key, value)?;
582 config
583 .server
584 .security
585 .validate()
586 .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
587 }
588 "password_require_uppercase" => {
589 config.server.security.password_policy.require_uppercase =
590 parse_value(&full_key, value)?
591 }
592 "password_require_lowercase" => {
593 config.server.security.password_policy.require_lowercase =
594 parse_value(&full_key, value)?
595 }
596 "password_require_digit" => {
597 config.server.security.password_policy.require_digit =
598 parse_value(&full_key, value)?
599 }
600 "password_require_special" => {
601 config.server.security.password_policy.require_special =
602 parse_value(&full_key, value)?
603 }
604 "password_max_age_days" => {
605 config.server.security.password_policy.max_age_days = parse_value(&full_key, value)?
606 }
607 "password_history_size" => {
608 config.server.security.password_policy.history_size = parse_value(&full_key, value)?
609 }
610 _ => return Err(format!("unknown configuration key '{full_key}'")),
611 },
612 "optimizer" => match key {
613 "max_iteration_rounds" => {
614 config.common.optimizer.max_iteration_rounds = parse_value(&full_key, value)?
615 }
616 "max_exploration_rounds" => {
617 config.common.optimizer.max_exploration_rounds = parse_value(&full_key, value)?
618 }
619 "enable_cost_model" => {
620 config.common.optimizer.enable_cost_model = parse_value(&full_key, value)?
621 }
622 "enable_multi_plan" => {
623 config.common.optimizer.enable_multi_plan = parse_value(&full_key, value)?
624 }
625 "enable_property_pruning" => {
626 config.common.optimizer.enable_property_pruning = parse_value(&full_key, value)?
627 }
628 "enable_adaptive_iteration" => {
629 config.common.optimizer.enable_adaptive_iteration = parse_value(&full_key, value)?
630 }
631 "stable_threshold" => {
632 config.common.optimizer.stable_threshold = parse_value(&full_key, value)?
633 }
634 "min_iteration_rounds" => {
635 config.common.optimizer.min_iteration_rounds = parse_value(&full_key, value)?
636 }
637 "statistics_sample_limit" => {
638 config.common.optimizer.statistics_sample_limit = parse_value(&full_key, value)?
639 }
640 "statistics_min_epoch_delta" => {
641 config.common.optimizer.statistics_min_epoch_delta = parse_value(&full_key, value)?
642 }
643 "storage_cost_profile" => {
644 config.common.optimizer.storage_cost_profile = parse_value(&full_key, value)?
645 }
646 "space_cost_profiles" => {
647 config.common.optimizer.space_cost_profiles = parse_value(&full_key, value)?
648 }
649 _ => return Err(format!("unknown configuration key '{full_key}'")),
650 },
651 "monitoring" => match key {
652 "enabled" => config.common.monitoring.enabled = parse_value(&full_key, value)?,
653 "memory_cache_size" => {
654 config.common.monitoring.memory_cache_size = parse_value(&full_key, value)?
655 }
656 "slow_query_threshold_ms" => {
657 config.common.monitoring.slow_query_threshold_ms = parse_value(&full_key, value)?
658 }
659 _ => return Err(format!("unknown configuration key '{full_key}'")),
660 },
661 "migration" => match key {
662 "batch_size" => {
663 config.common.migration.batch_size = parse_value(&full_key, value)?;
664 config
665 .common
666 .migration
667 .validate()
668 .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
669 }
670 "checkpoint_dir" => {
671 config.common.migration.checkpoint_dir = parse_value(&full_key, value)?
672 }
673 "backup_dir" => config.common.migration.backup_dir = parse_value(&full_key, value)?,
674 "lock_path" => config.common.migration.lock_path = parse_value(&full_key, value)?,
675 "drain_timeout_ms" => {
676 config.common.migration.drain_timeout_ms = parse_value(&full_key, value)?;
677 config
678 .common
679 .migration
680 .validate()
681 .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
682 }
683 "min_free_bytes" => {
684 config.common.migration.min_free_bytes = parse_value(&full_key, value)?
685 }
686 _ => return Err(format!("unknown configuration key '{full_key}'")),
687 },
688 _ => return Err(format!("unknown configuration key '{full_key}'")),
689 }
690 Ok(is_restart_required(section, key))
691}
692
693pub(crate) fn apply_config_update(
699 store: &parking_lot::RwLock<crate::config::Config>,
700 config_path: Option<&std::path::Path>,
701 section: &str,
702 key: &str,
703 value: &serde_json::Value,
704) -> Result<(bool, bool), String> {
705 let mut guard = store.write();
706 let old = get_config_value(&guard, section, key);
707 let requires_restart = set_config_value(&mut guard, section, key, value)?;
708 if let Err(e) = guard.validate() {
709 let _ = set_config_value(&mut guard, section, key, &old);
710 return Err(format!("update rejected by config validation: {e}"));
711 }
712 let Some(path) = config_path else {
713 return Ok((requires_restart, false));
714 };
715 if let Err(e) = guard.save(path) {
716 let _ = set_config_value(&mut guard, section, key, &old);
717 return Err(format!("failed to persist configuration: {e}"));
718 }
719 Ok((requires_restart, true))
720}
721
722pub(crate) const CONFIG_SECTIONS: &[&str] = &[
725 "database",
726 "transaction",
727 "log",
728 "auth",
729 "bootstrap",
730 "http",
731 "security",
732 "optimizer",
733 "monitoring",
734];
735
736pub(crate) fn section_keys(section: &str) -> Option<&'static [&'static str]> {
738 match section {
739 "database" => Some(&["host", "port", "storage_path", "max_sessions"]),
740 "transaction" => Some(&[
741 "default_timeout",
742 "max_concurrent_transactions",
743 "auto_commit",
744 ]),
745 "log" => Some(&[
746 "level",
747 "dir",
748 "basename",
749 "max_file_size_mb",
750 "max_files",
751 "stdout",
752 "json_format",
753 ]),
754 "auth" => Some(&[
755 "enable_authorize",
756 "failed_login_attempts",
757 "session_idle_timeout_secs",
758 "force_change_default_password",
759 "default_username",
760 "bcrypt_cost",
761 ]),
762 "bootstrap" => Some(&[
763 "auto_create_default_space",
764 "default_space_name",
765 "single_user_mode",
766 ]),
767 "http" => Some(&["cors_enabled", "cors_allowed_origins"]),
768 "security" => Some(&[
769 "password_min_length",
770 "password_require_uppercase",
771 "password_require_lowercase",
772 "password_require_digit",
773 "password_require_special",
774 "password_max_age_days",
775 "password_history_size",
776 ]),
777 "optimizer" => Some(&[
778 "max_iteration_rounds",
779 "max_exploration_rounds",
780 "enable_cost_model",
781 "enable_multi_plan",
782 "enable_property_pruning",
783 "enable_adaptive_iteration",
784 "stable_threshold",
785 "min_iteration_rounds",
786 "statistics_sample_limit",
787 "statistics_min_epoch_delta",
788 "storage_cost_profile",
789 "space_cost_profiles",
790 ]),
791 "monitoring" => Some(&["enabled", "memory_cache_size", "slow_query_threshold_ms"]),
792 _ => None,
793 }
794}
795
796pub(crate) fn resolve_config_section(module: Option<&str>) -> Result<Option<String>, String> {
801 match module {
802 None => Ok(None),
803 Some(raw) => {
804 let normalized = raw.trim().to_lowercase();
805 if CONFIG_SECTIONS.contains(&normalized.as_str()) {
806 Ok(Some(normalized))
807 } else {
808 Err(format!(
809 "unknown configuration module '{raw}': expected one of {}",
810 CONFIG_SECTIONS.join(", ")
811 ))
812 }
813 }
814 }
815}
816
817fn resolve_config_key(module: Option<&str>, name: &str) -> Result<(String, String), String> {
823 if let Some(section) = resolve_config_section(module)? {
824 return Ok((section, name.to_string()));
825 }
826 let mut owners = Vec::new();
827 for section in CONFIG_SECTIONS {
828 if section_keys(section).is_some_and(|keys| keys.contains(&name)) {
829 owners.push(*section);
830 }
831 }
832 match owners.as_slice() {
833 [] => Err(format!(
834 "unknown configuration key '{name}': qualify it with a module (one of {})",
835 CONFIG_SECTIONS.join(", ")
836 )),
837 [section] => Ok((section.to_string(), name.to_string())),
838 _ => Err(format!(
839 "ambiguous configuration key '{name}': present in {} — qualify it with a module",
840 owners.join(", ")
841 )),
842 }
843}
844
845fn display_config_value(value: &serde_json::Value) -> String {
848 match value {
849 serde_json::Value::String(text) => text.clone(),
850 serde_json::Value::Null => String::new(),
851 _ => value.to_string(),
852 }
853}
854
855pub(crate) fn resolve_show_configs(
860 config: &crate::config::Config,
861 module: Option<&str>,
862) -> Result<linkrs_core::DataSet, String> {
863 let section = resolve_config_section(module)?;
864 let sections: Vec<&str> = match section.as_deref() {
865 Some(name) => vec![name],
866 None => CONFIG_SECTIONS.to_vec(),
867 };
868 let mut rows = Vec::new();
869 for section in sections {
870 let keys = section_keys(section)
871 .ok_or_else(|| format!("unknown configuration module '{section}'"))?;
872 for key in keys {
873 rows.push(vec![
874 linkrs_core::Value::string(section),
875 linkrs_core::Value::string(*key),
876 linkrs_core::Value::string(display_config_value(&get_config_value(
877 config, section, key,
878 ))),
879 linkrs_core::Value::Bool(is_restart_required(section, key)),
880 ]);
881 }
882 }
883 Ok(linkrs_core::DataSet::from_rows(
884 rows,
885 vec![
886 "section".to_string(),
887 "key".to_string(),
888 "value".to_string(),
889 "requires_restart".to_string(),
890 ],
891 ))
892}
893
894pub(crate) fn apply_config_update_intent(
900 store: &parking_lot::RwLock<crate::config::Config>,
901 config_path: Option<&std::path::Path>,
902 module: Option<&str>,
903 name: &str,
904 value: &linkrs_core::Value,
905) -> Result<linkrs_core::DataSet, String> {
906 let (section, key) = resolve_config_key(module, name)?;
907 let json = crate::value::to_json(value.clone());
908 let (requires_restart, persisted) =
909 apply_config_update(store, config_path, §ion, &key, &json)?;
910 Ok(linkrs_core::DataSet::from_rows(
911 vec![vec![
912 linkrs_core::Value::string(format!("{section}.{key}")),
913 linkrs_core::Value::Bool(requires_restart),
914 linkrs_core::Value::Bool(persisted),
915 ]],
916 vec![
917 "updated".to_string(),
918 "requires_restart".to_string(),
919 "persisted".to_string(),
920 ],
921 ))
922}
923
924pub(crate) fn resolve_query_config_intent(
930 result: linkrs_api::api_core::QueryResult,
931 store: &parking_lot::RwLock<crate::config::Config>,
932 config_path: Option<&std::path::Path>,
933) -> Result<linkrs_api::api_core::QueryResult, String> {
934 use linkrs_api::api_core::QueryResult;
935 let linkrs_api::api_core::QueryResult {
936 execution,
937 metadata,
938 } = result;
939 let execution = match execution {
940 linkrs_query::executor::base::ExecutionResult::ConfigUpdate {
941 module,
942 name,
943 value,
944 } => linkrs_query::executor::base::ExecutionResult::DataSet {
945 data: apply_config_update_intent(store, config_path, module.as_deref(), &name, &value)?,
946 },
947 linkrs_query::executor::base::ExecutionResult::ShowConfigs { module } => {
948 let config = store.read();
949 linkrs_query::executor::base::ExecutionResult::DataSet {
950 data: resolve_show_configs(&config, module.as_deref())?,
951 }
952 }
953 other => other,
954 };
955 Ok(QueryResult::new(execution, metadata))
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961 use parking_lot::RwLock;
962 use std::sync::Arc;
963
964 fn test_store() -> Arc<RwLock<crate::config::Config>> {
965 Arc::new(RwLock::new(crate::config::Config::default()))
966 }
967
968 #[test]
969 fn test_resolve_config_section_vocabulary() {
970 assert_eq!(resolve_config_section(None).unwrap(), None);
971 assert_eq!(
972 resolve_config_section(Some("DATABASE")).unwrap(),
973 Some("database".to_string())
974 );
975 let error = resolve_config_section(Some("storage")).unwrap_err();
976 assert!(
977 error.contains("unknown configuration module"),
978 "unexpected error: {error}"
979 );
980 }
981
982 #[test]
983 fn test_apply_intent_updates_live_config() {
984 let store = test_store();
985 let receipt = apply_config_update_intent(
986 &store,
987 None,
988 Some("database"),
989 "max_sessions",
990 &linkrs_core::Value::Int(512),
991 )
992 .expect("valid update should apply");
993 assert_eq!(
994 receipt.col_names,
995 vec![
996 "updated".to_string(),
997 "requires_restart".to_string(),
998 "persisted".to_string()
999 ]
1000 );
1001 assert_eq!(
1002 store.read().common.database.max_sessions,
1003 512,
1004 "live config should reflect the applied intent"
1005 );
1006 apply_config_update_intent(
1008 &store,
1009 None,
1010 None,
1011 "max_sessions",
1012 &linkrs_core::Value::Int(256),
1013 )
1014 .expect("unqualified unique key should resolve");
1015 assert_eq!(store.read().common.database.max_sessions, 256);
1016 }
1017
1018 #[test]
1019 fn test_apply_intent_rejects_invalid_combination() {
1020 let store = test_store();
1021 let error = apply_config_update_intent(
1022 &store,
1023 None,
1024 Some("transaction"),
1025 "max_concurrent_transactions",
1026 &linkrs_core::Value::Int(0),
1027 )
1028 .expect_err("zero concurrent transactions must fail config validation");
1029 assert!(
1030 error.contains("config validation"),
1031 "unexpected error: {error}"
1032 );
1033 let after = store.read().common.transaction.max_concurrent_transactions;
1034 assert!(
1035 after > 0,
1036 "rejected update must not leave a zeroed live value"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_apply_intent_rejects_unknown_key() {
1042 let store = test_store();
1043 let error = apply_config_update_intent(
1044 &store,
1045 None,
1046 Some("database"),
1047 "no_such_key",
1048 &linkrs_core::Value::Int(1),
1049 )
1050 .unwrap_err();
1051 assert!(
1052 error.contains("unknown configuration key"),
1053 "unexpected error: {error}"
1054 );
1055 let error = apply_config_update_intent(
1056 &store,
1057 None,
1058 Some("no_such_module"),
1059 "max_sessions",
1060 &linkrs_core::Value::Int(1),
1061 )
1062 .unwrap_err();
1063 assert!(
1064 error.contains("unknown configuration module"),
1065 "unexpected error: {error}"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_apply_intent_rejects_type_mismatch() {
1071 let store = test_store();
1072 let before = store.read().common.database.max_sessions;
1073 let error = apply_config_update_intent(
1074 &store,
1075 None,
1076 Some("database"),
1077 "max_sessions",
1078 &linkrs_core::Value::string("not-a-number"),
1079 )
1080 .unwrap_err();
1081 assert!(error.contains("invalid value"), "unexpected error: {error}");
1082 assert_eq!(
1083 store.read().common.database.max_sessions,
1084 before,
1085 "failed update must not mutate live config"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_resolve_show_configs_lists_live_values() {
1091 let store = test_store();
1092 store.write().common.database.max_sessions = 777;
1093 let rows = resolve_show_configs(&store.read(), None).expect("listing should succeed");
1094 assert_eq!(
1095 rows.col_names,
1096 vec![
1097 "section".to_string(),
1098 "key".to_string(),
1099 "value".to_string(),
1100 "requires_restart".to_string()
1101 ]
1102 );
1103 let entry = rows
1104 .rows
1105 .iter()
1106 .find(|row| {
1107 row.first()
1108 .map(|v| v == &linkrs_core::Value::string("database"))
1109 .unwrap_or(false)
1110 && row
1111 .get(1)
1112 .map(|v| v == &linkrs_core::Value::string("max_sessions"))
1113 .unwrap_or(false)
1114 })
1115 .expect("database.max_sessions row should be listed");
1116 assert_eq!(
1117 entry.get(2),
1118 Some(&linkrs_core::Value::string("777")),
1119 "listing should reflect live configuration"
1120 );
1121
1122 let scoped = resolve_show_configs(&store.read(), Some("database"))
1123 .expect("scoped listing should succeed");
1124 assert!(
1125 scoped
1126 .rows
1127 .iter()
1128 .all(|row| row.first() == Some(&linkrs_core::Value::string("database"))),
1129 "scoped listing should only contain the requested section"
1130 );
1131 let error = resolve_show_configs(&store.read(), Some("storage")).unwrap_err();
1132 assert!(
1133 error.contains("unknown configuration module"),
1134 "unexpected error: {error}"
1135 );
1136 }
1137}