use crate::error::{ConfigError, HalldyllError, Result};
use std::path::Path;
use tracing::{debug, info};
use super::spec::DeployConfig;
#[derive(Debug, Default)]
pub struct ConfigParser {
base_path: Option<std::path::PathBuf>,
}
impl ConfigParser {
#[must_use]
pub const fn new() -> Self {
Self { base_path: None }
}
#[must_use]
pub fn with_base_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.base_path = Some(path.into());
self
}
pub fn load_file(&self, path: impl AsRef<Path>) -> Result<DeployConfig> {
let path = path.as_ref();
info!("Loading configuration from: {}", path.display());
if !path.exists() {
return Err(HalldyllError::Config(ConfigError::FileNotFound {
path: path.to_path_buf(),
}));
}
let content = std::fs::read_to_string(path).map_err(|e| {
HalldyllError::Config(ConfigError::ParseError {
message: format!("Failed to read file: {e}"),
location: Some(path.display().to_string()),
})
})?;
self.parse_yaml(&content, Some(path))
}
pub fn parse_yaml(&self, content: &str, source: Option<&Path>) -> Result<DeployConfig> {
debug!("Parsing YAML configuration");
let config: DeployConfig = serde_yaml::from_str(content).map_err(|e| {
let location = source.map(|p| p.display().to_string());
HalldyllError::Config(ConfigError::ParseError {
message: format!("YAML parse error: {e}"),
location,
})
})?;
debug!("Successfully parsed configuration for project: {}", config.project.name);
Ok(config)
}
pub fn load_with_env(&self, path: impl AsRef<Path>) -> Result<DeployConfig> {
let mut config = self.load_file(path)?;
Self::apply_env_overrides(&mut config);
Ok(config)
}
fn apply_env_overrides(config: &mut DeployConfig) {
if let Ok(name) = std::env::var("HALLDYLL_PROJECT_NAME") {
debug!("Overriding project.name from environment");
config.project.name = name;
}
if let Ok(env) = std::env::var("HALLDYLL_PROJECT_ENVIRONMENT") {
debug!("Overriding project.environment from environment");
config.project.environment = env;
}
if let Ok(region) = std::env::var("HALLDYLL_PROJECT_REGION") {
debug!("Overriding project.region from environment");
config.project.region = Some(region);
}
if let Ok(bucket) = std::env::var("HALLDYLL_STATE_BUCKET") {
debug!("Overriding state.bucket from environment");
config.state.bucket = Some(bucket);
}
if let Ok(prefix) = std::env::var("HALLDYLL_STATE_PREFIX") {
debug!("Overriding state.prefix from environment");
config.state.prefix = Some(prefix);
}
}
pub fn load_dotenv(&self) -> Result<()> {
let env_path = self
.base_path
.as_ref()
.map_or_else(|| std::path::PathBuf::from(".env"), |p| p.join(".env"));
if env_path.exists() {
info!("Loading environment from: {}", env_path.display());
dotenvy::from_path(&env_path).map_err(|e| {
HalldyllError::Config(ConfigError::ParseError {
message: format!("Failed to load .env file: {e}"),
location: Some(env_path.display().to_string()),
})
})?;
} else {
debug!(".env file not found at: {}", env_path.display());
}
Ok(())
}
pub fn validate_required_env(&self) -> Result<()> {
const REQUIRED_VARS: &[&str] = &["RUNPOD_API_KEY"];
for var in REQUIRED_VARS {
if std::env::var(var).is_err() {
return Err(HalldyllError::Config(ConfigError::MissingEnvVar {
name: (*var).to_string(),
}));
}
}
Ok(())
}
pub fn get_runpod_api_key() -> Result<String> {
std::env::var("RUNPOD_API_KEY").map_err(|_| {
HalldyllError::Config(ConfigError::MissingEnvVar {
name: String::from("RUNPOD_API_KEY"),
})
})
}
#[must_use]
pub fn get_hf_token() -> Option<String> {
std::env::var("HF_TOKEN").ok()
}
}
pub const DEFAULT_CONFIG_FILES: &[&str] = &[
"halldyll.deploy.yaml",
"halldyll.deploy.yml",
"deploy.yaml",
"deploy.yml",
];
pub fn find_config_file(start_dir: impl AsRef<Path>) -> Result<std::path::PathBuf> {
let start = start_dir.as_ref();
let mut current = start.to_path_buf();
loop {
for filename in DEFAULT_CONFIG_FILES {
let config_path = current.join(filename);
if config_path.exists() {
info!("Found configuration file: {}", config_path.display());
return Ok(config_path);
}
}
if !current.pop() {
break;
}
}
Err(HalldyllError::Config(ConfigError::FileNotFound {
path: start.join(DEFAULT_CONFIG_FILES[0]),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_config() {
let yaml = r"
project:
name: test-project
state:
backend: local
pods: []
";
let parser = ConfigParser::new();
let result = parser.parse_yaml(yaml, None);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.project.name, "test-project");
assert_eq!(config.project.environment, "dev");
}
#[test]
fn test_parse_full_config() {
let yaml = r#"
project:
name: halldyll-agent
environment: prod
region: EU
cloud_type: SECURE
compute_type: GPU
state:
backend: s3
bucket: halldyll-state
prefix: halldyll-agent/prod
pods:
- name: pod-text
gpu:
type: "NVIDIA A40"
count: 1
ports:
- "22/tcp"
- "8000/http"
volumes:
- name: hf-cache
mount: /root/.cache/huggingface
persistent: true
runtime:
image: ghcr.io/halldyll/pod-text:latest
env:
VLLM_PORT: "8000"
models:
- id: qwen2.5-14b-instruct
provider: huggingface
repo: Qwen/Qwen2.5-14B-Instruct
load:
engine: vllm
quant: awq
max_seq_len: 8192
"#;
let parser = ConfigParser::new();
let result = parser.parse_yaml(yaml, None);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.project.name, "halldyll-agent");
assert_eq!(config.pods.len(), 1);
assert_eq!(config.pods[0].name, "pod-text");
assert_eq!(config.pods[0].models.len(), 1);
}
}