v0.1.3-pre.013
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/document.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -82,7 +82,41 @@ impl ConfigDocumentEngine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let schema = self.load_json(&schema_file_id);
|
||||
return self.validate_document(document, &schema_file_id);
|
||||
}
|
||||
|
||||
pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||
let descriptor = self.registry.descriptor(file_id);
|
||||
let descriptor = match descriptor {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if descriptor.kind() != crate::ConfigFileKind::Config {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
|
||||
.with_context("file_id", file_id.as_str()),
|
||||
);
|
||||
}
|
||||
let schema_file_id = match descriptor.schema_file_id() {
|
||||
std::option::Option::Some(value) => value.clone(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
|
||||
.with_context("file_id", file_id.as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let path = self.registry.resolve_path(&self.bootstrap, file_id);
|
||||
let path = match path {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let document = ConfigJsonDocument { file_id: file_id.clone(), path, value };
|
||||
return self.validate_document(document, &schema_file_id);
|
||||
}
|
||||
|
||||
fn validate_document(&self, document: ConfigJsonDocument, schema_file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||
let schema = self.load_json(schema_file_id);
|
||||
let schema = match schema {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/environment.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Default local environment file read by Config from the process launch directory.
|
||||
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
||||
@@ -231,7 +231,7 @@ impl ConfigEnvironment {
|
||||
};
|
||||
}
|
||||
|
||||
fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
|
||||
pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
|
||||
let process = collect_process_environment(std::env::vars_os());
|
||||
let process = match process {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -245,6 +245,14 @@ impl ConfigEnvironment {
|
||||
return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() });
|
||||
}
|
||||
|
||||
pub(crate) const fn process_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||
return &self.process;
|
||||
}
|
||||
|
||||
pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||
return &self.dotenv;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> Self {
|
||||
return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) };
|
||||
@@ -288,7 +296,7 @@ fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collect
|
||||
return parse_dotenv_content(path, content.as_str());
|
||||
}
|
||||
|
||||
fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||
for (line_index, raw_line) in content.lines().enumerate() {
|
||||
let raw_line = if line_index == 0 { raw_line.trim_start_matches('\u{feff}') } else { raw_line };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/error.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
/// Error code used when a Config bootstrap argument is missing its value.
|
||||
pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value");
|
||||
@@ -60,3 +60,9 @@ pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode =
|
||||
|
||||
/// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract.
|
||||
pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid");
|
||||
|
||||
/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind.
|
||||
pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid");
|
||||
|
||||
/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit.
|
||||
pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed");
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// file: crates/ksp-config-lib/src/lib.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! KSP-owned application configuration facade.
|
||||
//!
|
||||
//! `0.1.3-pre.012` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution, generic composite
|
||||
//! `0.1.3-pre.013` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution, generic composite
|
||||
//! resolution and KSP/KSPB environment resolution through process + `.env` + fallback precedence. The standard Logging document remains the first registered
|
||||
//! runtime document. Environment-derived values preserve real/safe representations, sensitivity and provenance; the standard Logging profile can now be
|
||||
//! mapped explicitly to `ksp_logging_lib::LoggingSettings`. Persistence remains in a later bounded prerelease.
|
||||
//! mapped explicitly to `ksp_logging_lib::LoggingSettings`. Explicit management now owns typed Logging mutation, safe environment reports, privileged reveal calls and atomic JSON/`.env` persistence.
|
||||
|
||||
mod bootstrap;
|
||||
mod composite;
|
||||
@@ -17,6 +17,8 @@ mod document;
|
||||
mod environment;
|
||||
mod error;
|
||||
mod logging;
|
||||
mod management;
|
||||
mod persistence;
|
||||
mod profile;
|
||||
mod registry;
|
||||
mod sensitivity;
|
||||
@@ -83,6 +85,10 @@ pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID;
|
||||
pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED;
|
||||
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||
pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID;
|
||||
/// Error code used when an explicit management operation is unsupported or targets the wrong managed resource kind.
|
||||
pub use self::error::ERROR_CODE_MANAGEMENT_OPERATION_INVALID;
|
||||
/// Error code used when atomic managed Config or `.env` persistence fails before commit.
|
||||
pub use self::error::ERROR_CODE_PERSISTENCE_WRITE_FAILED;
|
||||
/// Error code used when an explicitly requested Config profile does not exist.
|
||||
pub use self::error::ERROR_CODE_PROFILE_NOT_FOUND;
|
||||
/// Error code used when a JSON Schema document is itself invalid.
|
||||
@@ -91,6 +97,28 @@ pub use self::error::ERROR_CODE_SCHEMA_INVALID;
|
||||
pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED;
|
||||
/// Effective standard Logging configuration mapped to `ksp_logging_lib::LoggingSettings`.
|
||||
pub use self::logging::ResolvedLoggingConfig;
|
||||
/// Result of one validated Config document persistence operation.
|
||||
pub use self::management::ConfigDocumentChangeReport;
|
||||
/// Result of one persistent `.env` mutation.
|
||||
pub use self::management::ConfigEnvironmentChangeReport;
|
||||
/// Safe desired/effective/shadow view of one KSP/KSPB environment variable.
|
||||
pub use self::management::ConfigEnvironmentReport;
|
||||
/// Raw source of one registered Config document read through the explicit management surface.
|
||||
pub use self::management::ConfigManagedSource;
|
||||
/// Explicit Config management facade for source inspection and validated persistent mutations.
|
||||
pub use self::management::ConfigManagement;
|
||||
/// Typed source contract for `config/std.logging.json`.
|
||||
pub use self::management::LoggingConfigDocument;
|
||||
/// Typed source contract for the standard Logging console output.
|
||||
pub use self::management::LoggingConsoleConfig;
|
||||
/// Typed source contract for one persistent Logging file output.
|
||||
pub use self::management::LoggingFileConfig;
|
||||
/// Typed source contract for one Logging sink selector/filter.
|
||||
pub use self::management::LoggingOutputFilterConfig;
|
||||
/// Typed source contract for one profile in `std.logging.json`.
|
||||
pub use self::management::LoggingProfileConfig;
|
||||
/// Typed source contract for one global Logging target override.
|
||||
pub use self::management::LoggingTargetFilterConfig;
|
||||
/// Source that selected an effective standard Config profile.
|
||||
pub use self::profile::ConfigProfileSelectionSource;
|
||||
/// Origin of one top-level value in a resolved standard Config profile.
|
||||
|
||||
1008
crates/ksp-config-lib/src/management.rs
Normal file
1008
crates/ksp-config-lib/src/management.rs
Normal file
File diff suppressed because it is too large
Load Diff
113
crates/ksp-config-lib/src/persistence.rs
Normal file
113
crates/ksp-config-lib/src/persistence.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
// file: crates/ksp-config-lib/src/persistence.rs
|
||||
// version: 1
|
||||
|
||||
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub(crate) fn atomic_write(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return atomic_write_with_policy(path, content, false);
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_private(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return atomic_write_with_policy(path, content, true);
|
||||
}
|
||||
|
||||
fn atomic_write_with_policy(path: &std::path::Path, content: &[u8], private_when_new: bool) -> ksp_core_lib::Result<()> {
|
||||
let parent = match path.parent() {
|
||||
std::option::Option::Some(value) if !value.as_os_str().is_empty() => value,
|
||||
_ => std::path::Path::new("."),
|
||||
};
|
||||
let filename = match path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||
_ => return std::result::Result::Err(persistence_error(path, "managed Config path has no UTF-8 file name")),
|
||||
};
|
||||
let existing_permissions = destination_permissions(path);
|
||||
let existing_permissions = match existing_permissions {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let temporary_id = NEXT_TEMPORARY_FILE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let temporary_name = format!(".{filename}.ksp-tmp-{}-{temporary_id}", std::process::id());
|
||||
let temporary_path = parent.join(temporary_name);
|
||||
let opened = std::fs::OpenOptions::new().write(true).create_new(true).open(temporary_path.as_path());
|
||||
let mut file = match opened {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be created", error)),
|
||||
};
|
||||
let permissions = apply_temporary_permissions(&file, existing_permissions, private_when_new);
|
||||
if let std::result::Result::Err(error) = permissions {
|
||||
cleanup_temporary_file(temporary_path.as_path());
|
||||
return std::result::Result::Err(persistence_io_error(path, "temporary Config file permissions cannot be applied", error));
|
||||
}
|
||||
let write = std::io::Write::write_all(&mut file, content);
|
||||
if let std::result::Result::Err(error) = write {
|
||||
cleanup_temporary_file(temporary_path.as_path());
|
||||
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be written", error));
|
||||
}
|
||||
let sync = file.sync_all();
|
||||
if let std::result::Result::Err(error) = sync {
|
||||
cleanup_temporary_file(temporary_path.as_path());
|
||||
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be synchronized", error));
|
||||
}
|
||||
drop(file);
|
||||
let rename = std::fs::rename(temporary_path.as_path(), path);
|
||||
if let std::result::Result::Err(error) = rename {
|
||||
cleanup_temporary_file(temporary_path.as_path());
|
||||
return std::result::Result::Err(persistence_io_error(path, "atomic Config file replacement failed", error));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn destination_permissions(path: &std::path::Path) -> ksp_core_lib::Result<std::option::Option<std::fs::Permissions>> {
|
||||
let metadata = std::fs::metadata(path);
|
||||
return match metadata {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value.permissions())),
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::result::Result::Ok(std::option::Option::None),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(persistence_io_error(path, "managed Config file metadata cannot be read before replacement", error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn apply_temporary_permissions(
|
||||
file: &std::fs::File,
|
||||
existing_permissions: std::option::Option<std::fs::Permissions>,
|
||||
private_when_new: bool,
|
||||
) -> std::io::Result<()> {
|
||||
if let std::option::Option::Some(permissions) = existing_permissions {
|
||||
return file.set_permissions(permissions);
|
||||
}
|
||||
return apply_new_file_permissions(file, private_when_new);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_new_file_permissions(file: &std::fs::File, private_when_new: bool) -> std::io::Result<()> {
|
||||
if private_when_new {
|
||||
let permissions = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600);
|
||||
return file.set_permissions(permissions);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_new_file_permissions(_file: &std::fs::File, _private_when_new: bool) -> std::io::Result<()> {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn cleanup_temporary_file(path: &std::path::Path) {
|
||||
let removal = std::fs::remove_file(path);
|
||||
if let std::result::Result::Err(error) = removal
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
ksp_logging_lib::warn!(target: "ksp-config-lib", domain: "config.persistence", path = %path.to_string_lossy(), error = %error, "unable to cleanup temporary Config file");
|
||||
}
|
||||
}
|
||||
|
||||
fn persistence_error(path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "Config persistence failed")
|
||||
.with_context("path", path.to_string_lossy().into_owned())
|
||||
.with_context("reason", reason);
|
||||
}
|
||||
|
||||
fn persistence_io_error(path: &std::path::Path, reason: &'static str, source: std::io::Error) -> ksp_core_lib::Error {
|
||||
return persistence_error(path, reason).with_source(source);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||
// version: 9
|
||||
// version: 10
|
||||
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity and
|
||||
//! Logging-adapter contracts.
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
|
||||
//! management contracts.
|
||||
|
||||
#[test]
|
||||
fn bootstrap_contract_is_available_from_crate_root() {
|
||||
@@ -178,3 +178,32 @@ fn logging_adapter_contract_is_available_from_crate_root() {
|
||||
assert_eq!(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID.code(), "effective_config_invalid");
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedLoggingConfig>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_contracts_are_available_from_crate_root() {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry)) = (bootstrap, registry) {
|
||||
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||
let management = ksp_config_lib::ConfigManagement::new(engine);
|
||||
let logging = management.load_logging_document();
|
||||
assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}");
|
||||
if let std::result::Result::Ok(mut logging) = logging {
|
||||
assert_eq!(logging.format_version(), 1);
|
||||
assert_eq!(logging.default_profile(), "local_dev");
|
||||
logging.set_logs_directory("public-api-management-test");
|
||||
assert_eq!(logging.logs_directory(), "public-api-management-test");
|
||||
assert_eq!(logging.profiles().len(), 1);
|
||||
assert_eq!(logging.profiles()[0].profile_id(), "local_dev");
|
||||
}
|
||||
}
|
||||
let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||
ksp_config_lib::ConfigManagement::reveal_effective_environment_value;
|
||||
let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||
ksp_config_lib::ConfigManagement::reveal_dotenv_value;
|
||||
let _ = (reveal_effective, reveal_dotenv);
|
||||
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
|
||||
}
|
||||
|
||||
357
crates/ksp-config-lib/unit_tests/management.rs
Normal file
357
crates/ksp-config-lib/unit_tests/management.rs
Normal file
@@ -0,0 +1,357 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/management.rs
|
||||
// version: 1
|
||||
|
||||
static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
struct ManagementFixture {
|
||||
root: std::path::PathBuf,
|
||||
config_path: std::path::PathBuf,
|
||||
dotenv_path: std::path::PathBuf,
|
||||
management: crate::ConfigManagement,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_management_read_remains_available_for_schema_invalid_source() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let invalid = "{\n \"format_version\": 1\n}\n";
|
||||
let write = std::fs::write(fixture.config_path.as_path(), invalid.as_bytes());
|
||||
assert!(write.is_ok(), "schema-invalid management fixture should be written");
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
cleanup_fixture(&fixture);
|
||||
return;
|
||||
},
|
||||
};
|
||||
let raw = fixture.management.read_source(&file_id);
|
||||
assert!(raw.is_ok(), "raw management read should not require schema validity: {raw:?}");
|
||||
if let std::result::Result::Ok(raw) = raw {
|
||||
assert_eq!(raw.content(), invalid);
|
||||
assert_eq!(raw.file_id(), &file_id);
|
||||
let debug = format!("{raw:?}");
|
||||
assert!(!debug.contains("format_version"), "raw source content must not be exposed by Debug");
|
||||
}
|
||||
let typed = fixture.management.load_logging_document();
|
||||
assert!(typed.is_err(), "typed management load must still require a valid source document");
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let document = fixture.management.load_logging_document();
|
||||
assert!(document.is_ok(), "committed Logging document should load through management: {document:?}");
|
||||
let mut document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
cleanup_fixture(&fixture);
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(document.format_version(), 1);
|
||||
assert_eq!(document.default_profile(), "local_dev");
|
||||
assert_eq!(document.profiles().len(), 1);
|
||||
document.set_logs_directory("managed-logs");
|
||||
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
|
||||
profile.set_default_filter("info");
|
||||
}
|
||||
let report = fixture.management.save_logging_document(&document);
|
||||
assert!(report.is_ok(), "valid typed Logging mutation should persist: {report:?}");
|
||||
if let std::result::Result::Ok(report) = report {
|
||||
assert!(report.source_changed());
|
||||
assert!(report.reload_required());
|
||||
}
|
||||
let persisted = std::fs::read_to_string(fixture.config_path.as_path());
|
||||
assert!(persisted.is_ok(), "persisted Logging document should remain readable");
|
||||
if let std::result::Result::Ok(persisted) = persisted {
|
||||
assert!(persisted.ends_with('\n'));
|
||||
assert!(persisted.contains("\"logs_directory\": \"managed-logs\""));
|
||||
assert!(persisted.contains("\"default_filter\": \"info\""));
|
||||
}
|
||||
let reloaded = fixture.management.load_logging_document();
|
||||
assert!(reloaded.is_ok(), "persisted Logging document should remain valid: {reloaded:?}");
|
||||
if let std::result::Result::Ok(reloaded) = reloaded {
|
||||
assert_eq!(reloaded.logs_directory(), "managed-logs");
|
||||
assert_eq!(reloaded.profiles()[0].default_filter(), "info");
|
||||
}
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_logging_candidate_does_not_modify_existing_file() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let before = std::fs::read(fixture.config_path.as_path());
|
||||
let before = match before {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
cleanup_fixture(&fixture);
|
||||
return;
|
||||
},
|
||||
};
|
||||
let document = fixture.management.load_logging_document();
|
||||
let mut document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
cleanup_fixture(&fixture);
|
||||
return;
|
||||
},
|
||||
};
|
||||
document.set_logs_directory("");
|
||||
let save = fixture.management.save_logging_document(&document);
|
||||
assert!(save.is_err(), "schema-invalid candidate must be rejected before persistence");
|
||||
let after = std::fs::read(fixture.config_path.as_path());
|
||||
assert_eq!(after.ok(), std::option::Option::Some(before));
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_logging_candidate_reports_no_reload() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let document = fixture.management.load_logging_document();
|
||||
let document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
cleanup_fixture(&fixture);
|
||||
return;
|
||||
},
|
||||
};
|
||||
let first = fixture.management.save_logging_document(&document);
|
||||
assert!(first.is_ok(), "normalization save should succeed: {first:?}");
|
||||
let second = fixture.management.save_logging_document(&document);
|
||||
assert!(second.is_ok(), "second identical save should succeed: {second:?}");
|
||||
if let std::result::Result::Ok(second) = second {
|
||||
assert!(!second.source_changed());
|
||||
assert!(!second.reload_required());
|
||||
}
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotenv_create_update_and_remove_round_trip_through_management() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let create = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "alpha");
|
||||
assert!(create.is_ok(), "managed .env create should succeed: {create:?}");
|
||||
if let std::result::Result::Ok(create) = create {
|
||||
assert!(create.source_changed());
|
||||
assert!(create.effective_changed());
|
||||
assert!(!create.shadowed_by_process_environment());
|
||||
assert!(create.reload_required());
|
||||
}
|
||||
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "hello world # 2");
|
||||
assert!(update.is_ok(), "managed .env update should support quoting: {update:?}");
|
||||
let revealed = fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE");
|
||||
assert_eq!(revealed.ok(), std::option::Option::Some(std::option::Option::Some("hello world # 2".to_owned())));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let metadata = std::fs::metadata(fixture.dotenv_path.as_path());
|
||||
assert!(metadata.is_ok(), "new managed .env permissions should be inspectable");
|
||||
if let std::result::Result::Ok(metadata) = metadata {
|
||||
let mode = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::mode(&metadata.permissions());
|
||||
assert_eq!(mode & 0o777, 0o600, "new .env must be private on Unix");
|
||||
}
|
||||
}
|
||||
let content = std::fs::read_to_string(fixture.dotenv_path.as_path());
|
||||
assert!(content.is_ok(), "managed .env should be readable");
|
||||
if let std::result::Result::Ok(content) = content {
|
||||
assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=\"hello world # 2\""));
|
||||
}
|
||||
let remove = fixture.management.remove_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE");
|
||||
assert!(remove.is_ok(), "managed .env remove should succeed: {remove:?}");
|
||||
if let std::result::Result::Ok(remove) = remove {
|
||||
assert!(remove.source_changed());
|
||||
assert!(remove.effective_changed());
|
||||
assert!(remove.reload_required());
|
||||
}
|
||||
assert_eq!(fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE").ok(), std::option::Option::Some(std::option::Option::None));
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotenv_comments_and_unrelated_entries_survive_targeted_mutation() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = "# local header\nEXTERNAL_VALUE=keep\n# managed comment\nKSP_PRE013_MANAGED_TEST_VALUE=before\n";
|
||||
let write = std::fs::write(fixture.dotenv_path.as_path(), source.as_bytes());
|
||||
assert!(write.is_ok(), "dotenv preservation fixture should be written");
|
||||
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "after");
|
||||
assert!(update.is_ok(), "targeted .env update should succeed: {update:?}");
|
||||
let content = std::fs::read_to_string(fixture.dotenv_path.as_path());
|
||||
assert!(content.is_ok(), "updated .env should remain readable");
|
||||
if let std::result::Result::Ok(content) = content {
|
||||
assert!(content.contains("# local header"));
|
||||
assert!(content.contains("EXTERNAL_VALUE=keep"));
|
||||
assert!(content.contains("# managed comment"));
|
||||
assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=after"));
|
||||
}
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_existing_dotenv_is_not_modified_by_management() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let invalid = "KSP_BROKEN\n";
|
||||
let write = std::fs::write(fixture.dotenv_path.as_path(), invalid.as_bytes());
|
||||
assert!(write.is_ok(), "invalid .env fixture should be written");
|
||||
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "value");
|
||||
assert!(update.is_err(), "management must reject mutation when existing .env syntax is invalid");
|
||||
assert_eq!(std::fs::read_to_string(fixture.dotenv_path.as_path()).ok(), std::option::Option::Some(invalid.to_owned()));
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_environment_name_is_rejected_without_creating_dotenv() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let update = fixture.management.set_dotenv_value("OTHER_TOKEN", "value");
|
||||
assert!(update.is_err(), "non-KSP variable must be rejected");
|
||||
assert!(!fixture.dotenv_path.exists(), "rejected mutation must not create .env");
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_report_redacts_secret_but_explicit_reveal_returns_real_value() {
|
||||
let fixture = management_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "SECRET-CANARY-PRE013";
|
||||
let write = std::fs::write(fixture.dotenv_path.as_path(), format!("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN={canary}\n"));
|
||||
assert!(write.is_ok(), "secret management fixture should be written");
|
||||
let reports = fixture.management.environment_report();
|
||||
assert!(reports.is_ok(), "safe environment report should load: {reports:?}");
|
||||
if let std::result::Result::Ok(reports) = reports {
|
||||
let report = reports.iter().find(|report| -> bool {
|
||||
return report.variable_name() == "KSP_SECRET_PRE013_MANAGED_TEST_TOKEN";
|
||||
});
|
||||
assert!(report.is_some(), "secret entry should appear in management report");
|
||||
if let std::option::Option::Some(report) = report {
|
||||
assert_eq!(report.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||
assert_eq!(report.desired_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||
assert_eq!(report.effective_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||
assert!(!report.shadowed_by_process_environment());
|
||||
assert!(!format!("{report:?}").contains(canary));
|
||||
}
|
||||
}
|
||||
let reveal = fixture.management.reveal_effective_environment_value("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN");
|
||||
assert_eq!(reveal.ok(), std::option::Option::Some(std::option::Option::Some(canary.to_owned())));
|
||||
cleanup_fixture(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_shadowing_report_distinguishes_desired_and_effective_changes() {
|
||||
let mut process_before = std::collections::BTreeMap::<String, String>::new();
|
||||
process_before.insert("KSP_MODE".to_owned(), "process".to_owned());
|
||||
let mut dotenv_before = std::collections::BTreeMap::<String, String>::new();
|
||||
dotenv_before.insert("KSP_MODE".to_owned(), "desired-a".to_owned());
|
||||
let before = crate::ConfigEnvironment::from_maps(process_before.clone(), dotenv_before);
|
||||
let mut dotenv_after = std::collections::BTreeMap::<String, String>::new();
|
||||
dotenv_after.insert("KSP_MODE".to_owned(), "desired-b".to_owned());
|
||||
let after = crate::ConfigEnvironment::from_maps(process_before, dotenv_after);
|
||||
let report = super::environment_change_report("KSP_MODE", &before, &after);
|
||||
assert!(report.source_changed());
|
||||
assert!(!report.effective_changed());
|
||||
assert!(report.shadowed_by_process_environment());
|
||||
assert!(!report.reload_required());
|
||||
let reports = super::build_environment_reports(&after);
|
||||
assert!(reports.is_ok(), "shadow report fixture should build: {reports:?}");
|
||||
if let std::result::Result::Ok(reports) = reports {
|
||||
assert_eq!(reports.len(), 1);
|
||||
assert_eq!(reports[0].desired_safe_value(), std::option::Option::Some("desired-b"));
|
||||
assert_eq!(reports[0].effective_safe_value(), std::option::Option::Some("process"));
|
||||
assert_eq!(reports[0].effective_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||
assert!(reports[0].shadowed_by_process_environment());
|
||||
}
|
||||
}
|
||||
|
||||
fn management_fixture() -> ksp_core_lib::Result<ManagementFixture> {
|
||||
let fixture_id = NEXT_FIXTURE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root = std::env::temp_dir().join(format!("ksp-pre013-management-{}-{fixture_id}", std::process::id()));
|
||||
let cleanup = std::fs::remove_dir_all(root.as_path());
|
||||
if let std::result::Result::Err(error) = cleanup
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to cleanup previous management fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let config_root = root.join("config");
|
||||
let schema_root = config_root.join("schemas");
|
||||
let create = std::fs::create_dir_all(schema_root.as_path());
|
||||
if let std::result::Result::Err(error) = create {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to create management fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let source_config = workspace.join("config/std.logging.json");
|
||||
let source_schema = workspace.join("config/schemas/std.logging.schema.json");
|
||||
let config_path = config_root.join("std.logging.json");
|
||||
let schema_path = schema_root.join("std.logging.schema.json");
|
||||
let copy_config = std::fs::copy(source_config.as_path(), config_path.as_path());
|
||||
if let std::result::Result::Err(error) = copy_config {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management Config fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let copy_schema = std::fs::copy(source_schema.as_path(), schema_path.as_path());
|
||||
if let std::result::Result::Err(error) = copy_schema {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management schema fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root.as_path(), schema_root.as_path());
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = crate::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||
let dotenv_path = root.join(".env");
|
||||
let management = crate::ConfigManagement::with_dotenv_path(engine, dotenv_path.clone());
|
||||
return std::result::Result::Ok(ManagementFixture { root, config_path, dotenv_path, management });
|
||||
}
|
||||
|
||||
fn cleanup_fixture(fixture: &ManagementFixture) {
|
||||
let cleanup = std::fs::remove_dir_all(fixture.root.as_path());
|
||||
if let std::result::Result::Err(error) = cleanup
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
eprintln!("unable to cleanup Config management fixture {}: {error}", fixture.root.display());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user