0.5.1-pre.005
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
<!-- file: ks-config/CHANGELOG.md -->
|
||||
<!-- version: 14 -->
|
||||
<!-- version: 15 -->
|
||||
|
||||
# CHANGELOG — ks-config
|
||||
|
||||
## `0.5.1-pre.005`
|
||||
|
||||
- retire définitivement le bloc `logging` de `ProfileConfig` et du schéma général ;
|
||||
- aligne le contrat général et le desktop sur `config/app.config.json`, validé par `config/schemas/app.config.schema.json` ;
|
||||
- conserve des profils applicatifs indépendants du document logging possédé par `ks-logging` ;
|
||||
- ajoute `config/example.app.config.json` comme exemple minimal conforme.
|
||||
|
||||
## `0.5.1-pre.004`
|
||||
|
||||
- migre le sélecteur d'environnement vers `KS_ENV_FILE` et les placeholders PostgreSQL/Helius vers `KS_SECRET_*` ;
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
<!-- file: ks-config/README.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# ks-config
|
||||
|
||||
`ks-config` définit le contrat de configuration typé du workspace, son schéma JSON embarqué et les fonctions de chargement, résolution d’environnement, validation et sérialisation.
|
||||
`ks-config` définit le contrat de configuration générale typé du workspace, son schéma JSON embarqué et les fonctions de chargement, résolution d’environnement, validation et sérialisation.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
- exposer `AppConfig` et les sections de configuration publiques ;
|
||||
- valider le JSON contre le schéma embarqué ;
|
||||
- exposer `AppConfig` et les sections de configuration générale ;
|
||||
- valider le JSON général contre `config/schemas/app.config.schema.json` ;
|
||||
- appliquer les invariants métier après désérialisation ;
|
||||
- charger `.env`, ou le fichier explicitement sélectionné par `KS_ENV_FILE`, depuis la racine du workspace ;
|
||||
- charger `.env`, ou le fichier sélectionné par `KS_ENV_FILE`, depuis la racine du workspace ;
|
||||
- résoudre les placeholders namespacés `${KS_*}` / `${KB_*}` et leurs fallbacks ;
|
||||
- sélectionner le profil actif ;
|
||||
- exporter les types nécessaires au frontend avec `ts-rs`.
|
||||
- sélectionner le profil applicatif actif ;
|
||||
- exporter les types généraux nécessaires au frontend avec `ts-rs`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
La crate n’initialise ni le logging, ni PostgreSQL, ni les transports et ne manipule aucun secret de wallet. Elle fournit uniquement la configuration validée à ces consommateurs.
|
||||
`ks-config` ne possède plus le contrat logging. `LoggingConfig`, les routes, filtres, profils logging et `config/schemas/logging.config.schema.json` appartiennent à `ks-logging`.
|
||||
|
||||
La crate n’initialise ni PostgreSQL, ni les transports et ne manipule aucun secret de wallet. Elle fournit uniquement la configuration générale validée à ces consommateurs.
|
||||
|
||||
## Surface publique
|
||||
|
||||
Les principales fonctions sont `read_config_json_file_with_environment`, `parse_config_json`, `validate_config`, `validate_config_json_schema`, `active_profile` et les sérialiseurs JSON. Les types publics couvrent les profils, endpoints, listeners, logging, base de données, wallet, exécution et démonstration.
|
||||
Les principales fonctions sont `read_config_json_file_with_environment`, `parse_config_json`, `validate_config`, `validate_config_json_schema`, `active_profile` et les sérialiseurs JSON.
|
||||
|
||||
Les types publics couvrent les profils applicatifs, endpoints, listeners, base de données, wallet, exécution et démonstration.
|
||||
|
||||
## Relations
|
||||
|
||||
- dépend de `ks-core` pour les erreurs structurées ;
|
||||
- alimente `ks-logging`, `ks-store`, `ks-onchain-transport`, `ks-pipeline`, `ks-wallet` et les applications ;
|
||||
- utilise [`../config/example.config.json`](../config/example.config.json) et [`../config/schema.config.json`](../config/schema.config.json) comme exemple utilisateur et contrat de schéma actifs.
|
||||
- fournit les profils généraux à `ks-store`, `ks-onchain-transport`, `ks-pipeline`, `ks-wallet` et aux applications ;
|
||||
- fournit à `ks-logging` les helpers génériques de chargement `.env` et de résolution des placeholders ;
|
||||
- embarque [`../config/schemas/app.config.schema.json`](../config/schemas/app.config.schema.json) ;
|
||||
- utilise [`../config/app.config.json`](../config/app.config.json) comme configuration générale chargée par défaut et [`../config/example.app.config.json`](../config/example.app.config.json) comme exemple minimal conforme.
|
||||
|
||||
## Statut
|
||||
|
||||
La configuration actuelle est fonctionnelle et validée, mais son format `0.4.8` reste monolithique. Le cadrage `0.5.0-pre.002` confirme que `0.5.1` séparera la configuration générale et le logging en documents et schémas distincts, applique les namespaces d’environnement `KS_*` / `KB_*` par ownership et séparera les représentations source, runtime et publiques afin qu’aucun secret résolu ne soit exposé.
|
||||
Le split config/logging est effectif : un profil applicatif ne transporte plus de bloc logging et la sélection logging est indépendante. La prerelease suivante sépare les représentations source/runtime/public/diagnostic et ferme l’exposition des secrets résolus.
|
||||
|
||||
## Documents
|
||||
|
||||
- [Utilisation](USAGE.md)
|
||||
- [Travaux restants](TODO.md)
|
||||
- [Historique](CHANGELOG.md)
|
||||
- [Architecture](../docs/architecture/ARCHITECTURE.md)
|
||||
- [Configuration locale](../config/README.md)
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<!-- file: ks-config/TODO.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 7 -->
|
||||
|
||||
# TODO — ks-config
|
||||
|
||||
## Série `0.5.x`
|
||||
|
||||
- [ ] `0.5.1` - séparer la configuration générale et le logging en documents et schémas JSON distincts.
|
||||
- [ ] `0.5.1` - rendre la sélection du profil logging indépendante du profil généraliste.
|
||||
- [ ] `0.5.1` - définir un propriétaire unique du contrat source logging et supprimer la conversion manuelle du desktop.
|
||||
- [ ] `0.5.1` - appliquer à l’exécution les classes `KS_SECRET_*` / `KB_SECRET_*`, `KS_PUBLIC_*` / `KB_PUBLIC_*` et internes, avec propagation de sensibilité aux valeurs composées.
|
||||
- [ ] `0.5.1` - séparer les représentations source, runtime résolue, publique et diagnostic.
|
||||
- [ ] `0.5.1` - interdire qu’un secret résolu soit sérialisé, loggé, inclus dans une erreur ou transmis via Tauri.
|
||||
- [ ] `0.5.1` - supprimer l’exposition frontend de `AppConfig` et `ProfileConfig` résolus complets.
|
||||
- [ ] Contrat - maintenir l’identité entre chaque schéma embarqué et son fichier sous `config/`.
|
||||
- [ ] Tests - ajouter les tests d’API externe, de migration multi-fichiers et les canaris de non-divulgation.
|
||||
- [ ] Documentation - mettre à jour guides, exemples et `.env.example` seulement avec l’implémentation correspondante.
|
||||
- [ ] Intégration - coordonner la migration avec logging, transports, pipeline, scénarios et applications.
|
||||
- [ ] Contrat - maintenir l’identité entre chaque schéma embarqué et son fichier sous `config/schemas/`.
|
||||
- [ ] Tests - ajouter les tests d’API externe et les canaris de non-divulgation.
|
||||
- [ ] Documentation - maintenir guides, exemples et `.env.example` avec l’implémentation correspondante.
|
||||
- [ ] Intégration - coordonner les surfaces publiques sûres avec logging, transports, pipeline, scénarios et applications.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ks-config/USAGE.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 7 -->
|
||||
|
||||
# Utilisation de ks-config
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
```rust
|
||||
let config_result = ks_config::read_config_json_file_with_environment(
|
||||
std::path::Path::new("config/example.config.json"),
|
||||
std::path::Path::new("config/app.config.json"),
|
||||
std::path::Path::new("."),
|
||||
);
|
||||
let config = match config_result {
|
||||
@@ -20,7 +20,9 @@ let profile = match ks_config::active_profile(&config) {
|
||||
};
|
||||
```
|
||||
|
||||
Cette fonction charge l’environnement du workspace, résout les placeholders puis applique successivement le schéma JSON, la désérialisation typée et les invariants métier.
|
||||
Cette API charge l’environnement du workspace, résout les placeholders puis applique successivement le schéma JSON général, la désérialisation typée et les invariants métier.
|
||||
|
||||
Le fichier chargé par défaut par le desktop est `config/app.config.json`. `KS_CONFIG_PATH` permet de remplacer explicitement ce chemin.
|
||||
|
||||
## Chargement de l’environnement
|
||||
|
||||
@@ -35,18 +37,20 @@ if let Some(path) = report.loaded_path {
|
||||
}
|
||||
```
|
||||
|
||||
`EnvironmentLoadReport` indique le fichier chargé, s’il existe. Par défaut, `ks-config` sélectionne `.env`; `KS_ENV_FILE` permet de choisir explicitement un autre fichier sans écraser les variables déjà présentes dans le processus. Les placeholders non résolus sans fallback restent visibles afin que la validation ou le consommateur puisse les signaler explicitement. Les contrats d’environnement du workspace utilisent désormais `KS_*` pour Khadhroony Solana et `KB_*` pour les futurs besoins réellement propres au Bot.
|
||||
`EnvironmentLoadReport` indique le fichier chargé. Par défaut, `ks-config` sélectionne `.env`; `KS_ENV_FILE` permet d’en choisir un autre sans écraser les variables déjà présentes dans le processus.
|
||||
|
||||
Les contrats d’environnement utilisent `KS_*` pour Khadhroony Solana et `KB_*` pour les besoins réellement propres au Bot.
|
||||
|
||||
## Résolution explicite des placeholders
|
||||
|
||||
```rust
|
||||
let raw = r#"{"databaseUrl":"${KS_SECRET_POSTGRES_DEVNET_URL:-postgres://localhost/kb}"}"#;
|
||||
let raw = r#"{"databaseUrl":"${KS_SECRET_POSTGRES_DEVNET_URL:-postgres://localhost/ks}"}"#;
|
||||
let resolved = ks_config::resolve_environment_placeholders(raw);
|
||||
|
||||
assert!(resolved.contains("databaseUrl"));
|
||||
```
|
||||
|
||||
Cette API retourne actuellement une chaîne résolue ordinaire et peut donc contenir des secrets issus de l’environnement. Elle doit rester strictement backend et ne doit pas être utilisée pour afficher, logger ou transmettre le JSON résolu. `0.5.1` remplacera cette frontière par une résolution conservant la classification de sensibilité.
|
||||
Cette API retourne encore une chaîne ordinaire pouvant contenir des secrets résolus. Elle reste strictement backend jusqu’à l’introduction de la représentation sensible de `0.5.1-pre.006`.
|
||||
|
||||
## Validation et parsing
|
||||
|
||||
@@ -65,29 +69,6 @@ if let Err(error) = ks_config::validate_config(&config) {
|
||||
|
||||
`parse_config_json` effectue déjà les deux validations ; les appels séparés servent aux outils de diagnostic.
|
||||
|
||||
## Sérialisation
|
||||
|
||||
```rust
|
||||
let compact = match ks_config::serialize_config_json(&config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let pretty = match ks_config::serialize_config_json_pretty(&config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let write_result = std::fs::write("config/generated.config.json", pretty);
|
||||
if let Err(error) = write_result {
|
||||
return Err(ks_core::Error::new(
|
||||
"config_write_failed",
|
||||
format!("cannot write generated configuration: {error}"),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
La configuration est validée avant sérialisation.
|
||||
|
||||
## Schéma embarqué
|
||||
|
||||
```rust
|
||||
@@ -97,15 +78,11 @@ let schema_value = match ks_config::config_json_schema_value() {
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let property_count = schema_value
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map_or(0, serde_json::Map::len);
|
||||
|
||||
println!("embedded schema bytes={}, properties={property_count}", schema_text.len());
|
||||
println!("embedded app schema bytes={}", schema_text.len());
|
||||
assert!(schema_value.is_object());
|
||||
```
|
||||
|
||||
Le schéma actif est aussi disponible sous [`../config/schema.config.json`](../config/schema.config.json). Le fichier [`../config/example.config.json`](../config/example.config.json) fournit un exemple utilisateur complet.
|
||||
Le schéma actif est [`../config/schemas/app.config.schema.json`](../config/schemas/app.config.schema.json). Les fichiers [`../config/app.config.json`](../config/app.config.json) et [`../config/example.app.config.json`](../config/example.app.config.json) doivent tous deux être conformes.
|
||||
|
||||
## Sélection du profil actif
|
||||
|
||||
@@ -120,24 +97,47 @@ println!("http endpoints: {}", profile.solana.http_endpoints.len());
|
||||
println!("websocket endpoints: {}", profile.solana.ws_endpoints.len());
|
||||
```
|
||||
|
||||
Le profil applicatif ne contient plus de configuration logging. Le document logging et son `active_profile` sont chargés par `ks-logging`.
|
||||
|
||||
## Sérialisation
|
||||
|
||||
```rust
|
||||
let pretty = match ks_config::serialize_config_json_pretty(&config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let write_result = std::fs::write("config/generated.app.config.json", pretty);
|
||||
if let Err(error) = write_result {
|
||||
return Err(ks_core::Error::new(
|
||||
"config_write_failed",
|
||||
format!("cannot write generated configuration: {error}"),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
La configuration est validée avant sérialisation.
|
||||
|
||||
## Types publics importants
|
||||
|
||||
- `AppConfig`, `ProfileConfig`, `AppSectionConfig` ;
|
||||
- `DatabaseConfig`, `PostgresConfig`, `SqliteConfig`, `DataConfig` ;
|
||||
- `SolanaConfig`, `HttpEndpointConfig`, `WsEndpointConfig`, `EndpointRoleConfig` ;
|
||||
- `ListenerConfig` et ses variantes ;
|
||||
- `LoggingConfig`, `LogTargetConfig`, `LogTargetFilterConfig` ;
|
||||
- `WalletConfig`, `ExecutionConfig`, `DemoConfig`.
|
||||
|
||||
Les types logging ne font plus partie de `ks-config`.
|
||||
|
||||
## Erreurs et invariants
|
||||
|
||||
Les erreurs utilisent `ks_core::Error` avec un code stable. Les validations couvrent notamment l’unicité des profils, l’existence du profil actif, les URLs, les rôles d’endpoints, les limites d’exécution, les routes de logging et les contraintes wallet.
|
||||
Les erreurs utilisent `ks_core::Error` avec un code stable. Les validations couvrent notamment l’unicité des profils, l’existence du profil actif, les URLs, les rôles d’endpoints, les limites d’exécution et les contraintes wallet.
|
||||
|
||||
## Tests instructifs
|
||||
|
||||
Les tests `example_config_validates_against_schema`, `example_config_parses_and_resolves_active_profile` et `example_config_routes_global_and_operational_crate_files` démontrent le contrat complet de l’exemple actif. Les tests `parser_rejects_*` et `schema_rejects_*` documentent les invariants refusés.
|
||||
Les tests vérifient séparément `app.config.json` et `example.app.config.json`, le roundtrip du document général et les invariants `parser_rejects_*` / `schema_rejects_*`.
|
||||
|
||||
## Limites
|
||||
|
||||
- format JSON uniquement ;
|
||||
- la crate valide les références et paramètres, mais n’ouvre aucune connexion externe.
|
||||
- aucune connexion externe n’est ouverte par la crate ;
|
||||
- la politique de camouflage des valeurs résolues appartient à `0.5.1-pre.006`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: ks-config/src/lib.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Khadhroony Bot3 workspace configuration contract and loading helpers.
|
||||
//! Khadhroony Solana application configuration contract and loading helpers.
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -37,12 +37,6 @@ pub use self::settings::HttpEndpointConfig;
|
||||
pub use self::settings::ListenerConfig;
|
||||
/// Exposes the log listener configuration type.
|
||||
pub use self::settings::LogListenerConfig;
|
||||
/// Exposes the logging target configuration type.
|
||||
pub use self::settings::LogTargetConfig;
|
||||
/// Exposes the logging target filter configuration type.
|
||||
pub use self::settings::LogTargetFilterConfig;
|
||||
/// Exposes the logging configuration type.
|
||||
pub use self::settings::LoggingConfig;
|
||||
/// Exposes the PostgreSQL configuration type.
|
||||
pub use self::settings::PostgresConfig;
|
||||
/// Exposes the profile configuration type.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// file: ks-config/src/settings.rs
|
||||
// version: 19
|
||||
// version: 20
|
||||
|
||||
//! Typed configuration models shared by applications and workers.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
const CONFIG_JSON_SCHEMA: &str = include_str!("../../config/schema.config.json");
|
||||
const CONFIG_JSON_SCHEMA: &str = include_str!("../../config/schemas/app.config.schema.json");
|
||||
|
||||
/// Root configuration containing every named profile.
|
||||
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
|
||||
@@ -28,8 +28,6 @@ pub struct ProfileConfig {
|
||||
pub name: std::string::String,
|
||||
/// Application metadata.
|
||||
pub app: AppSectionConfig,
|
||||
/// Logging configuration.
|
||||
pub logging: LoggingConfig,
|
||||
/// Database configuration.
|
||||
pub database: DatabaseConfig,
|
||||
/// Local data directories.
|
||||
@@ -59,61 +57,6 @@ pub struct AppSectionConfig {
|
||||
pub auto_reconnect_default: bool,
|
||||
}
|
||||
|
||||
/// Logging configuration shared by apps and worker processes.
|
||||
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/ks_config/settings/LoggingConfig.ts"
|
||||
)]
|
||||
pub struct LoggingConfig {
|
||||
/// Default log level.
|
||||
pub default_level: std::string::String,
|
||||
/// Output targets.
|
||||
pub targets: std::vec::Vec<LogTargetConfig>,
|
||||
/// Target-specific filters.
|
||||
pub target_filters: std::vec::Vec<LogTargetFilterConfig>,
|
||||
}
|
||||
|
||||
/// Single logging output target.
|
||||
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/ks_config/settings/LogTargetConfig.ts"
|
||||
)]
|
||||
pub struct LogTargetConfig {
|
||||
/// Output target name.
|
||||
pub name: std::string::String,
|
||||
/// Enables this target.
|
||||
pub enabled: bool,
|
||||
/// Sink kind, such as console or file.
|
||||
pub sink: std::string::String,
|
||||
/// Minimum level for this target.
|
||||
pub level: std::string::String,
|
||||
/// File path for file sinks or an empty string for console sinks.
|
||||
pub path: std::string::String,
|
||||
/// Rotation mode for file sinks.
|
||||
pub rotation: std::string::String,
|
||||
/// Message format, such as human or json.
|
||||
pub format: std::string::String,
|
||||
/// Enables ANSI escape sequences for this target.
|
||||
pub ansi: bool,
|
||||
/// Included tracing targets or globs.
|
||||
pub targets: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Per-target logging filter.
|
||||
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/ks_config/settings/LogTargetFilterConfig.ts"
|
||||
)]
|
||||
pub struct LogTargetFilterConfig {
|
||||
/// Tracing target or crate prefix.
|
||||
pub target: std::string::String,
|
||||
/// Level assigned to the target.
|
||||
pub level: std::string::String,
|
||||
}
|
||||
|
||||
/// Database backend configuration.
|
||||
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
|
||||
#[ts(
|
||||
@@ -650,10 +593,6 @@ fn validate_profile(profile: &ProfileConfig) -> ks_core::Result<()> {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_logging(&profile.logging) {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_database(&profile.database) {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
@@ -685,109 +624,6 @@ fn validate_app_section(config: &AppSectionConfig) -> ks_core::Result<()> {
|
||||
return require_non_empty(&config.environment, "app.environment");
|
||||
}
|
||||
|
||||
fn validate_logging(config: &LoggingConfig) -> ks_core::Result<()> {
|
||||
match validate_log_level(&config.default_level, "logging.default_level") {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
if config.targets.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_targets_empty",
|
||||
"at least one logging target is required",
|
||||
));
|
||||
}
|
||||
let mut enabled_count = 0_u32;
|
||||
let mut names = std::collections::BTreeSet::<std::string::String>::new();
|
||||
for target in &config.targets {
|
||||
if target.enabled {
|
||||
enabled_count += 1;
|
||||
}
|
||||
if !names.insert(target.name.clone()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_target_duplicate",
|
||||
target.name.clone(),
|
||||
));
|
||||
}
|
||||
match validate_log_target(target) {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
for filter in &config.target_filters {
|
||||
match require_non_empty(&filter.target, "logging.target_filters.target") {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_log_level(&filter.level, "logging.target_filters.level") {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
if enabled_count == 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_no_enabled_targets",
|
||||
"at least one logging target must be enabled",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_log_target(config: &LogTargetConfig) -> ks_core::Result<()> {
|
||||
match require_non_empty(&config.name, "logging.targets.name") {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_log_level(&config.level, "logging.targets.level") {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
if config.sink != "console" && config.sink != "file" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_sink_invalid",
|
||||
config.sink.clone(),
|
||||
));
|
||||
}
|
||||
if config.rotation != "none"
|
||||
&& config.rotation != "never"
|
||||
&& config.rotation != "daily"
|
||||
&& config.rotation != "hourly"
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_rotation_invalid",
|
||||
config.rotation.clone(),
|
||||
));
|
||||
}
|
||||
if config.format != "human"
|
||||
&& config.format != "compact"
|
||||
&& config.format != "pretty"
|
||||
&& config.format != "json"
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_format_invalid",
|
||||
config.format.clone(),
|
||||
));
|
||||
}
|
||||
if config.sink == "file" && config.path.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_file_path_empty",
|
||||
config.name.clone(),
|
||||
));
|
||||
}
|
||||
if config.sink == "console" && !config.path.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"logging_console_path_not_empty",
|
||||
config.name.clone(),
|
||||
));
|
||||
}
|
||||
for target in &config.targets {
|
||||
match require_non_empty(target, "logging.targets.targets") {
|
||||
std::result::Result::Ok(()) => (),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_database(config: &DatabaseConfig) -> ks_core::Result<()> {
|
||||
if config.backend != "postgres" && config.backend != "sqlite" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
@@ -1179,22 +1015,6 @@ fn validate_wallet_execution_pair(
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_log_level(value: &str, field_name: &str) -> ks_core::Result<()> {
|
||||
if value == "trace"
|
||||
|| value == "debug"
|
||||
|| value == "info"
|
||||
|| value == "warn"
|
||||
|| value == "error"
|
||||
|| value == "off"
|
||||
{
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"log_level_invalid",
|
||||
format!("{field_name}: {value}"),
|
||||
));
|
||||
}
|
||||
|
||||
fn require_non_empty(value: &str, field_name: &str) -> ks_core::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
@@ -1225,13 +1045,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
const EXAMPLE_CONFIG: &str = include_str!("../../config/example.config.json");
|
||||
const DEFAULT_CONFIG: &str = include_str!("../../config/app.config.json");
|
||||
const EXAMPLE_CONFIG: &str = include_str!("../../config/example.app.config.json");
|
||||
|
||||
fn parse_example_value() -> serde_json::Value {
|
||||
let result = serde_json::from_str::<serde_json::Value>(EXAMPLE_CONFIG);
|
||||
fn parse_default_value() -> serde_json::Value {
|
||||
let result = serde_json::from_str::<serde_json::Value>(DEFAULT_CONFIG);
|
||||
match result {
|
||||
std::result::Result::Ok(value) => return value,
|
||||
std::result::Result::Err(error) => panic!("example config must be valid JSON: {error}"),
|
||||
std::result::Result::Err(error) => panic!("default app config must be valid JSON: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1243,57 +1064,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tracing_crate_names() -> std::vec::Vec<std::string::String> {
|
||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = match manifest_dir.parent() {
|
||||
std::option::Option::Some(path) => path,
|
||||
std::option::Option::None => panic!("workspace root must exist"),
|
||||
};
|
||||
let read_result = std::fs::read_dir(workspace_root);
|
||||
let entries = match read_result {
|
||||
std::result::Result::Ok(entries) => entries,
|
||||
std::result::Result::Err(error) => panic!("workspace must be readable: {error}"),
|
||||
};
|
||||
let mut names = std::vec::Vec::<std::string::String>::new();
|
||||
for entry_result in entries {
|
||||
let entry = match entry_result {
|
||||
std::result::Result::Ok(entry) => entry,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("workspace entry must be readable: {error}")
|
||||
},
|
||||
};
|
||||
let cargo_path = entry.path().join("Cargo.toml");
|
||||
if !cargo_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let cargo_result = std::fs::read_to_string(&cargo_path);
|
||||
let cargo_toml = match cargo_result {
|
||||
std::result::Result::Ok(content) => content,
|
||||
std::result::Result::Err(error) => panic!("Cargo.toml must be readable: {error}"),
|
||||
};
|
||||
if !cargo_toml.lines().any(|line| return line.trim() == "tracing.workspace = true") {
|
||||
continue;
|
||||
}
|
||||
let mut in_package = false;
|
||||
for line in cargo_toml.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "[package]" {
|
||||
in_package = true;
|
||||
continue;
|
||||
}
|
||||
if in_package && trimmed.starts_with('[') {
|
||||
break;
|
||||
}
|
||||
if in_package && trimmed.starts_with("name = ") {
|
||||
names.push(trimmed.trim_start_matches("name = ").trim_matches('"').to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
return names;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_text_is_valid_json() {
|
||||
let result = super::config_json_schema_value();
|
||||
@@ -1301,110 +1071,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_validates_against_schema() {
|
||||
let result = super::validate_config_json_schema(EXAMPLE_CONFIG);
|
||||
assert!(result.is_ok());
|
||||
fn default_and_example_app_configs_validate_against_schema() {
|
||||
assert!(super::validate_config_json_schema(DEFAULT_CONFIG).is_ok());
|
||||
assert!(super::validate_config_json_schema(EXAMPLE_CONFIG).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_routes_global_and_operational_crate_files() {
|
||||
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
|
||||
let config = match config_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
|
||||
};
|
||||
let operational_crates = tracing_crate_names();
|
||||
assert!(!operational_crates.is_empty());
|
||||
for profile in config.profiles {
|
||||
let directory = match profile.name.as_str() {
|
||||
"local_devnet" => "devnet",
|
||||
"mainnet_research" => "mainnet_research",
|
||||
"mainnet" => "mainnet",
|
||||
value => panic!("unexpected example profile: {value}"),
|
||||
};
|
||||
for (suffix, level, format) in [
|
||||
("debug.log", "debug", "human"),
|
||||
("info.log", "info", "human"),
|
||||
("error.jsonl", "error", "json"),
|
||||
] {
|
||||
let expected_path = format!("logs/{directory}/{suffix}");
|
||||
assert!(profile.logging.targets.iter().any(|target| {
|
||||
return target.enabled
|
||||
&& target.path == expected_path
|
||||
&& target.level == level
|
||||
&& target.format == format
|
||||
&& target.targets == std::vec!["*".to_string()];
|
||||
}));
|
||||
}
|
||||
assert!(profile.logging.targets.iter().any(|target| {
|
||||
return target.enabled
|
||||
&& target.path == format!("logs/{directory}/app.log")
|
||||
&& target.level == "debug"
|
||||
&& target.format == "human"
|
||||
&& target.targets == std::vec!["kb-app-demo-desktop".to_string()];
|
||||
}));
|
||||
for crate_name in &operational_crates {
|
||||
for (suffix, level, format) in [
|
||||
("debug.log", "debug", "human"),
|
||||
("info.log", "info", "human"),
|
||||
("error.jsonl", "error", "json"),
|
||||
] {
|
||||
let route_directory = if crate_name == "ks-pipeline-demo-scenarios" {
|
||||
"ks-pipeline"
|
||||
} else {
|
||||
crate_name.as_str()
|
||||
};
|
||||
let expected_path = format!("logs/{directory}/{route_directory}/{suffix}");
|
||||
let route_exists = profile.logging.targets.iter().any(|target| {
|
||||
return target.enabled
|
||||
&& target.path == expected_path
|
||||
&& target.level == level
|
||||
&& target.format == format
|
||||
&& target.targets.iter().any(|value| return value == crate_name);
|
||||
});
|
||||
assert!(
|
||||
route_exists,
|
||||
"profile {} is missing the canonical {} route for {} at {}",
|
||||
profile.name, level, crate_name, expected_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_uses_canonical_wallet_tracing_routes_without_embedded_secrets() {
|
||||
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
|
||||
let config = match config_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
|
||||
};
|
||||
assert!(!EXAMPLE_CONFIG.contains("api-key=95e73621"));
|
||||
assert!(!EXAMPLE_CONFIG.contains("\"ks_wallet\""));
|
||||
for profile in config.profiles {
|
||||
assert!(profile.logging.targets.iter().any(|target| {
|
||||
return target.enabled && target.targets == std::vec!["ks-wallet".to_string()];
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_uses_canonical_pipeline_tracing_routes() {
|
||||
let source = include_str!("../../config/example.config.json");
|
||||
assert!(source.contains("\"ks-pipeline\""));
|
||||
assert!(source.contains("logs/devnet/ks-pipeline/debug.log"));
|
||||
assert!(source.contains("logs/mainnet_research/ks-pipeline/info.log"));
|
||||
assert!(source.contains("logs/mainnet/ks-pipeline/error.jsonl"));
|
||||
assert!(!source.contains("ks_pipeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_parses_and_resolves_active_profile() {
|
||||
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
|
||||
fn default_app_config_parses_and_resolves_active_profile() {
|
||||
let config_result = super::parse_config_json(DEFAULT_CONFIG);
|
||||
assert!(config_result.is_ok());
|
||||
let config = match config_result {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
|
||||
std::result::Result::Err(error) => panic!("default app config must parse: {error}"),
|
||||
};
|
||||
let active_result = super::active_profile(&config);
|
||||
assert!(active_result.is_ok());
|
||||
@@ -1417,18 +1095,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_serializes_and_roundtrips() {
|
||||
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
|
||||
fn default_app_config_serializes_and_roundtrips() {
|
||||
let config_result = super::parse_config_json(DEFAULT_CONFIG);
|
||||
assert!(config_result.is_ok());
|
||||
let config = match config_result {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
|
||||
std::result::Result::Err(error) => panic!("default app config must parse: {error}"),
|
||||
};
|
||||
let serialized_result = super::serialize_config_json_pretty(&config);
|
||||
assert!(serialized_result.is_ok());
|
||||
let serialized = match serialized_result {
|
||||
std::result::Result::Ok(serialized) => serialized,
|
||||
std::result::Result::Err(error) => panic!("example config must serialize: {error}"),
|
||||
std::result::Result::Err(error) => panic!("default app config must serialize: {error}"),
|
||||
};
|
||||
let reparsed_result = super::parse_config_json(&serialized);
|
||||
assert!(reparsed_result.is_ok());
|
||||
@@ -1441,7 +1119,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_missing_active_profile() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["active_profile"] = serde_json::Value::String("missing_profile".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
let result = super::parse_config_json(&raw_json);
|
||||
@@ -1449,8 +1127,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_config_separates_runtime_postgres_profiles() {
|
||||
let value = parse_example_value();
|
||||
fn default_app_config_separates_runtime_postgres_profiles() {
|
||||
let value = parse_default_value();
|
||||
assert_eq!(
|
||||
value["profiles"][0]["database"]["postgres"]["url"],
|
||||
serde_json::Value::String("${KS_SECRET_POSTGRES_DEVNET_URL}".to_string())
|
||||
@@ -1467,7 +1145,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schema_rejects_profile_enabled_field() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["enabled"] = serde_json::Value::Bool(true);
|
||||
let raw_json = value_to_json(&value);
|
||||
let result = super::validate_config_json_schema(&raw_json);
|
||||
@@ -1476,7 +1154,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_duplicate_profile_names() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][1]["name"] = serde_json::Value::String("local_devnet".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
let result = super::parse_config_json(&raw_json);
|
||||
@@ -1485,7 +1163,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schema_rejects_unknown_endpoint_secret_field() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["solana"]["http_endpoints"][0]["secret_env_field"] =
|
||||
serde_json::Value::String("KS_SECRET_HELIUS_API_KEY".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1495,7 +1173,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schema_rejects_runtime_idls_directory_field() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["data"]["idls_directory"] =
|
||||
serde_json::Value::String("idls".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1505,7 +1183,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schema_rejects_unsupported_transport_surface() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["solana"]["advanced_streams"] = serde_json::json!([]);
|
||||
let raw_json = value_to_json(&value);
|
||||
let result = super::validate_config_json_schema(&raw_json);
|
||||
@@ -1514,7 +1192,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_invalid_http_endpoint_url() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["solana"]["http_endpoints"][0]["url"] =
|
||||
serde_json::Value::String("wss://api.devnet.solana.com".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1524,7 +1202,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_invalid_ws_endpoint_url() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["solana"]["ws_endpoints"][0]["url"] =
|
||||
serde_json::Value::String("https://api.devnet.solana.com".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1532,19 +1210,9 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_file_logging_target_without_path() {
|
||||
let mut value = parse_example_value();
|
||||
value["profiles"][0]["logging"]["targets"][1]["path"] =
|
||||
serde_json::Value::String("".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
let result = super::parse_config_json(&raw_json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_invalid_temporary_wallet_alias() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["wallet"]["temporary_wallet_alias"] =
|
||||
serde_json::Value::String("../wallet".to_string());
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1554,7 +1222,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_persistent_disabled_temporary_wallet() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["wallet"]["temporary_wallet_enabled"] = serde_json::Value::Bool(false);
|
||||
let raw_json = value_to_json(&value);
|
||||
let result = super::parse_config_json(&raw_json);
|
||||
@@ -1563,7 +1231,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_enabled_cluster_without_spend_limit() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["execution"]["devnet_max_spend_lamports"] =
|
||||
serde_json::Value::Number(serde_json::Number::from(0));
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1579,7 +1247,7 @@ mod tests {
|
||||
"confirmation_poll_interval_ms",
|
||||
"confirmation_max_attempts",
|
||||
] {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["execution"][field] =
|
||||
serde_json::Value::Number(serde_json::Number::from(0));
|
||||
let raw_json = value_to_json(&value);
|
||||
@@ -1590,7 +1258,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_http_role_with_subscriptions() {
|
||||
let mut value = parse_example_value();
|
||||
let mut value = parse_default_value();
|
||||
value["profiles"][0]["solana"]["http_endpoints"][0]["roles"][0]["max_subscriptions"] =
|
||||
serde_json::Value::Number(serde_json::Number::from(1));
|
||||
let raw_json = value_to_json(&value);
|
||||
|
||||
Reference in New Issue
Block a user