Skip to main content

floe_core/profile/
parse.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use yaml_rust2::yaml::Hash;
5use yaml_rust2::Yaml;
6
7use crate::config::yaml_decode::{
8    hash_get, load_yaml, validate_known_keys, yaml_array, yaml_hash, yaml_string,
9};
10use crate::profile::types::{
11    ProfileConfig, ProfileExecution, ProfileMetadata, ProfileRunner, ProfileRunnerAuth,
12    ProfileValidation, PROFILE_API_VERSION, PROFILE_KIND,
13};
14use crate::{ConfigError, FloeResult};
15
16/// Parse a profile YAML file from disk.
17pub fn parse_profile(path: &Path) -> FloeResult<ProfileConfig> {
18    let docs = load_yaml(path)?;
19    if docs.is_empty() {
20        return Err(Box::new(ConfigError("profile YAML is empty".to_string())));
21    }
22    if docs.len() > 1 {
23        return Err(Box::new(ConfigError(
24            "profile YAML contains multiple documents; expected one".to_string(),
25        )));
26    }
27    parse_profile_doc(&docs[0])
28}
29
30/// Parse a profile from a YAML string (used in tests).
31pub fn parse_profile_from_str(contents: &str) -> FloeResult<ProfileConfig> {
32    use yaml_rust2::YamlLoader;
33    let docs = YamlLoader::load_from_str(contents)
34        .map_err(|e| Box::new(ConfigError(format!("YAML parse error: {e}"))))?;
35    if docs.is_empty() {
36        return Err(Box::new(ConfigError("profile YAML is empty".to_string())));
37    }
38    if docs.len() > 1 {
39        return Err(Box::new(ConfigError(
40            "profile YAML contains multiple documents; expected one".to_string(),
41        )));
42    }
43    parse_profile_doc(&docs[0])
44}
45
46fn parse_profile_doc(doc: &Yaml) -> FloeResult<ProfileConfig> {
47    let root = yaml_hash(doc, "profile")?;
48    validate_known_keys(
49        root,
50        "profile",
51        &[
52            "apiVersion",
53            "kind",
54            "metadata",
55            "execution",
56            "variables",
57            "validation",
58        ],
59    )?;
60
61    let api_version = get_required_string(root, "apiVersion", "profile")?;
62    if api_version != PROFILE_API_VERSION {
63        return Err(Box::new(ConfigError(format!(
64            "profile.apiVersion: expected \"{PROFILE_API_VERSION}\", got \"{api_version}\""
65        ))));
66    }
67
68    let kind = get_required_string(root, "kind", "profile")?;
69    if kind != PROFILE_KIND {
70        return Err(Box::new(ConfigError(format!(
71            "profile.kind: expected \"{PROFILE_KIND}\", got \"{kind}\""
72        ))));
73    }
74
75    let metadata_yaml = hash_get(root, "metadata").ok_or_else(|| {
76        Box::new(ConfigError("profile.metadata is required".to_string()))
77            as Box<dyn std::error::Error + Send + Sync>
78    })?;
79    let metadata = parse_metadata(metadata_yaml)?;
80
81    let execution = match hash_get(root, "execution") {
82        Some(value) => Some(parse_execution(value)?),
83        None => None,
84    };
85
86    let variables = match hash_get(root, "variables") {
87        Some(value) => parse_variables(value)?,
88        None => HashMap::new(),
89    };
90
91    let validation = match hash_get(root, "validation") {
92        Some(value) => Some(parse_validation(value)?),
93        None => None,
94    };
95
96    Ok(ProfileConfig {
97        api_version,
98        kind,
99        metadata,
100        execution,
101        variables,
102        validation,
103    })
104}
105
106fn parse_metadata(value: &Yaml) -> FloeResult<ProfileMetadata> {
107    let hash = yaml_hash(value, "profile.metadata")?;
108    validate_known_keys(
109        hash,
110        "profile.metadata",
111        &["name", "description", "env", "tags"],
112    )?;
113
114    let name = get_required_string(hash, "name", "profile.metadata")?;
115    let description = get_optional_string(hash, "description", "profile.metadata")?;
116    let env = get_optional_string(hash, "env", "profile.metadata")?;
117
118    let tags = match hash_get(hash, "tags") {
119        Some(value) => {
120            let arr = yaml_array(value, "profile.metadata.tags")?;
121            let mut tags = Vec::with_capacity(arr.len());
122            for item in arr {
123                tags.push(yaml_string(item, "profile.metadata.tags[]")?);
124            }
125            Some(tags)
126        }
127        None => None,
128    };
129
130    Ok(ProfileMetadata {
131        name,
132        description,
133        env,
134        tags,
135    })
136}
137
138fn parse_execution(value: &Yaml) -> FloeResult<ProfileExecution> {
139    let hash = yaml_hash(value, "profile.execution")?;
140    validate_known_keys(hash, "profile.execution", &["runner"])?;
141
142    let runner_yaml = hash_get(hash, "runner").ok_or_else(|| {
143        Box::new(ConfigError(
144            "profile.execution.runner is required".to_string(),
145        )) as Box<dyn std::error::Error + Send + Sync>
146    })?;
147    let runner = parse_runner(runner_yaml)?;
148
149    Ok(ProfileExecution { runner })
150}
151
152fn parse_runner(value: &Yaml) -> FloeResult<ProfileRunner> {
153    let hash = yaml_hash(value, "profile.execution.runner")?;
154    validate_known_keys(
155        hash,
156        "profile.execution.runner",
157        &[
158            "type",
159            "command",
160            "args",
161            "timeout_seconds",
162            "ttl_seconds_after_finished",
163            "poll_interval_seconds",
164            "secrets",
165            "workspace_url",
166            "existing_cluster_id",
167            "config_uri",
168            "python_file_uri",
169            "job_name",
170            "auth",
171            "env_parameters",
172        ],
173    )?;
174
175    let runner_type = get_required_string(hash, "type", "profile.execution.runner")?;
176
177    let command = get_optional_string(hash, "command", "profile.execution.runner")?;
178    let args = get_optional_string_list(hash, "args", "profile.execution.runner")?;
179    let timeout_seconds = get_optional_u64(hash, "timeout_seconds", "profile.execution.runner")?;
180    let ttl_seconds_after_finished = get_optional_u64(
181        hash,
182        "ttl_seconds_after_finished",
183        "profile.execution.runner",
184    )?;
185    let poll_interval_seconds =
186        get_optional_u64(hash, "poll_interval_seconds", "profile.execution.runner")?;
187    let secrets = get_optional_string_list(hash, "secrets", "profile.execution.runner")?;
188    let workspace_url = get_optional_string(hash, "workspace_url", "profile.execution.runner")?;
189    let existing_cluster_id =
190        get_optional_string(hash, "existing_cluster_id", "profile.execution.runner")?;
191    let config_uri = get_optional_string(hash, "config_uri", "profile.execution.runner")?;
192    let python_file_uri = get_optional_string(hash, "python_file_uri", "profile.execution.runner")?;
193    let job_name = get_optional_string(hash, "job_name", "profile.execution.runner")?;
194    let auth = parse_runner_auth(hash_get(hash, "auth"))?;
195    let env_parameters = match hash_get(hash, "env_parameters") {
196        Some(value) => Some(extract_string_map(
197            yaml_hash(value, "profile.execution.runner.env_parameters")?,
198            "profile.execution.runner.env_parameters",
199        )?),
200        None => None,
201    };
202
203    Ok(ProfileRunner {
204        runner_type,
205        command,
206        args,
207        timeout_seconds,
208        ttl_seconds_after_finished,
209        poll_interval_seconds,
210        secrets,
211        workspace_url,
212        existing_cluster_id,
213        config_uri,
214        python_file_uri,
215        job_name,
216        auth,
217        env_parameters,
218    })
219}
220
221fn parse_runner_auth(value: Option<&Yaml>) -> FloeResult<Option<ProfileRunnerAuth>> {
222    let Some(value) = value else {
223        return Ok(None);
224    };
225
226    let hash = yaml_hash(value, "profile.execution.runner.auth")?;
227    validate_known_keys(
228        hash,
229        "profile.execution.runner.auth",
230        &["service_principal_oauth_ref"],
231    )?;
232
233    Ok(Some(ProfileRunnerAuth {
234        service_principal_oauth_ref: get_optional_string(
235            hash,
236            "service_principal_oauth_ref",
237            "profile.execution.runner.auth",
238        )?,
239    }))
240}
241
242fn parse_variables(value: &Yaml) -> FloeResult<HashMap<String, String>> {
243    let hash = yaml_hash(value, "profile.variables")?;
244    extract_string_map(hash, "profile.variables")
245}
246
247fn parse_validation(value: &Yaml) -> FloeResult<ProfileValidation> {
248    let hash = yaml_hash(value, "profile.validation")?;
249    validate_known_keys(hash, "profile.validation", &["strict"])?;
250
251    let strict = match hash_get(hash, "strict") {
252        Some(Yaml::Boolean(b)) => Some(*b),
253        Some(_) => {
254            return Err(Box::new(ConfigError(
255                "profile.validation.strict must be a boolean".to_string(),
256            )))
257        }
258        None => None,
259    };
260
261    Ok(ProfileValidation { strict })
262}
263
264fn get_required_string(hash: &Hash, key: &str, ctx: &str) -> FloeResult<String> {
265    let value = hash_get(hash, key).ok_or_else(|| {
266        Box::new(ConfigError(format!("{ctx}.{key} is required")))
267            as Box<dyn std::error::Error + Send + Sync>
268    })?;
269    yaml_string(value, &format!("{ctx}.{key}"))
270}
271
272fn get_optional_string(hash: &Hash, key: &str, ctx: &str) -> FloeResult<Option<String>> {
273    match hash_get(hash, key) {
274        None => Ok(None),
275        Some(value) => yaml_string(value, &format!("{ctx}.{key}")).map(Some),
276    }
277}
278
279fn get_optional_string_list(hash: &Hash, key: &str, ctx: &str) -> FloeResult<Option<Vec<String>>> {
280    match hash_get(hash, key) {
281        None => Ok(None),
282        Some(value) => {
283            let arr = yaml_array(value, &format!("{ctx}.{key}"))?;
284            let mut items = Vec::with_capacity(arr.len());
285            for item in arr {
286                items.push(yaml_string(item, &format!("{ctx}.{key}[]"))?);
287            }
288            Ok(Some(items))
289        }
290    }
291}
292
293fn get_optional_u64(hash: &Hash, key: &str, ctx: &str) -> FloeResult<Option<u64>> {
294    match hash_get(hash, key) {
295        None => Ok(None),
296        Some(Yaml::Integer(v)) if *v >= 0 => Ok(Some(*v as u64)),
297        Some(_) => Err(Box::new(ConfigError(format!(
298            "{ctx}.{key} must be a non-negative integer"
299        )))),
300    }
301}
302
303fn extract_string_map(hash: &Hash, context: &str) -> FloeResult<HashMap<String, String>> {
304    let mut map = HashMap::new();
305    for (key, value) in hash {
306        let key_str = yaml_string(key, context)?;
307        let value_str = yaml_string(value, context)?;
308        map.insert(key_str, value_str);
309    }
310    Ok(map)
311}