Skip to main content

dre_core/
profiles.rs

1//! `profiles.yml`: connection definitions, kept outside the project.
2//!
3//! Two sections keep database connections apart from delivery targets:
4//!
5//! ```yaml
6//! sources:
7//!   warehouse:
8//!     target: dev
9//!     targets:
10//!       dev: {type: duckdb, path: dev.duckdb}
11//! destinations:
12//!   client_sftp:
13//!     target: dev
14//!     targets:
15//!       dev: {type: sftp, host: sftp.example.com}
16//! ```
17//!
18//! A source profile is referenced by `default_profile`/`profile:`, a destination profile by
19//! `output.destination.profile`; each is looked up in its own section only.
20//!
21//! Location: `--profiles-dir` > `DRE_PROFILES_DIR` > `~/.dre`, one file, never merged. These are
22//! read directly at startup, never through the `env_var()` Jinja function. `env_var()` calls
23//! *inside* the file are rendered at run time, just before a connection config is handed to a
24//! plugin.
25
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30use serde_yaml_ng::Value;
31
32use crate::diag::Diagnostics;
33use crate::yaml::YamlFile;
34
35pub const PROFILES_FILE: &str = "profiles.yml";
36
37/// The built-in destination type: copying bytes to a local path needs no plugin (ADR 0003).
38/// `profile: local` works without a profiles.yml entry; defining a `local` profile overrides it.
39pub const LOCAL_TYPE: &str = "local";
40
41/// What `profile: local` resolves to when profiles.yml doesn't define it, for every target.
42pub static BUILTIN_LOCAL: std::sync::LazyLock<ProfileTarget> = std::sync::LazyLock::new(|| ProfileTarget {
43    kind: LOCAL_TYPE.into(),
44    fields: serde_json::Map::new(),
45});
46
47/// Which section of profiles.yml a profile lives in.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum Role {
50    Source,
51    Destination,
52}
53
54impl Role {
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Role::Source => "source",
58            Role::Destination => "destination",
59        }
60    }
61    /// The profiles.yml section key.
62    pub fn section(self) -> &'static str {
63        match self {
64            Role::Source => "sources",
65            Role::Destination => "destinations",
66        }
67    }
68}
69
70/// One environment of a profile: a plugin type and its connection fields.
71#[derive(Debug, Clone, Serialize)]
72pub struct ProfileTarget {
73    #[serde(rename = "type")]
74    pub kind: String,
75    /// Every other field, passed through to the plugin unvalidated by core.
76    #[serde(skip)]
77    pub fields: serde_json::Map<String, serde_json::Value>,
78}
79
80#[derive(Debug, Clone, Serialize)]
81pub struct Profile {
82    /// The default target.
83    pub target: String,
84    pub targets: BTreeMap<String, ProfileTarget>,
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct Profiles {
89    /// Where DRE looked for the file.
90    pub path: PathBuf,
91    /// Why that directory: `--profiles-dir`, `DRE_PROFILES_DIR`, `the project directory` or `~/.dre`.
92    pub found_by: &'static str,
93    /// `None` when the file doesn't exist; loading problems are reported when it's needed.
94    pub file: Option<YamlFile>,
95    pub sources: BTreeMap<String, Profile>,
96    pub destinations: BTreeMap<String, Profile>,
97}
98
99/// Resolve the profiles directory: `--profiles-dir` > `DRE_PROFILES_DIR` > `~/.dre`.
100/// `dre init` writes here; projects look in their own directory first (see [`locate`]).
101pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
102    locate(cli, None).0
103}
104
105/// Find the profiles directory for a project, with the reason it was chosen:
106/// `--profiles-dir` > `DRE_PROFILES_DIR` > the project directory (when it holds a
107/// profiles.yml) > `~/.dre`. With the default `--project-dir .` this is dbt's order.
108pub fn locate(cli: Option<&Path>, project: Option<&Path>) -> (PathBuf, &'static str) {
109    if let Some(p) = cli {
110        return (p.to_path_buf(), "--profiles-dir");
111    }
112    if let Some(p) = std::env::var_os("DRE_PROFILES_DIR").filter(|p| !p.is_empty()) {
113        return (PathBuf::from(p), "DRE_PROFILES_DIR");
114    }
115    if let Some(p) = project.filter(|p| p.join(PROFILES_FILE).is_file()) {
116        return (p.to_path_buf(), "the project directory");
117    }
118    (crate::dre_home(), "~/.dre")
119}
120
121impl Profiles {
122    pub fn load(dir: &Path, found_by: &'static str, diags: &mut Diagnostics) -> Profiles {
123        let path = crate::slash(&dir.join(PROFILES_FILE));
124        let mut out = Profiles {
125            path: path.clone(),
126            found_by,
127            ..Default::default()
128        };
129        if !path.is_file() {
130            return out;
131        }
132        let Some(yf) = YamlFile::load(&path, path.clone(), diags) else {
133            // Parsed badly: treat as present-but-empty so references don't pile up extra errors.
134            out.file = Some(YamlFile {
135                display: path,
136                text: String::new(),
137                value: Value::Null,
138            });
139            return out;
140        };
141        let file = Some(path.clone());
142        match &yf.value {
143            Value::Mapping(m) => {
144                for (k, v) in m {
145                    let key = k.as_str().unwrap_or_default();
146                    let role = match key {
147                        "sources" => Role::Source,
148                        "destinations" => Role::Destination,
149                        _ => {
150                            diags.error(
151                                "invalid-profiles",
152                                file.clone(),
153                                yf.line_of(key, None),
154                                format!(
155                                    "unknown profiles.yml section `{key}`; profiles go under `sources:` or `destinations:`"
156                                ),
157                            );
158                            continue;
159                        }
160                    };
161                    let section_line = yf.line_of(key, None);
162                    let parsed = match v {
163                        Value::Mapping(ps) => ps
164                            .iter()
165                            .filter_map(|(k, v)| {
166                                let name = k.as_str()?;
167                                parse_profile(role, name, v, &yf, section_line, diags)
168                                    .map(|p| (name.to_string(), p))
169                            })
170                            .collect(),
171                        Value::Null => BTreeMap::new(),
172                        _ => {
173                            diags.error(
174                                "invalid-profiles",
175                                file.clone(),
176                                section_line,
177                                format!("`{key}` must be a map of profile names"),
178                            );
179                            BTreeMap::new()
180                        }
181                    };
182                    match role {
183                        Role::Source => out.sources = parsed,
184                        Role::Destination => out.destinations = parsed,
185                    }
186                }
187            }
188            Value::Null => {}
189            _ => diags.error(
190                "invalid-profiles",
191                file,
192                None,
193                "profiles.yml must be a map with `sources:` and/or `destinations:`",
194            ),
195        }
196        out.file = Some(yf);
197        out
198    }
199
200    pub fn exists(&self) -> bool {
201        self.file.is_some()
202    }
203
204    fn section(&self, role: Role) -> &BTreeMap<String, Profile> {
205        match role {
206            Role::Source => &self.sources,
207            Role::Destination => &self.destinations,
208        }
209    }
210
211    pub fn get(&self, role: Role, name: &str) -> Option<&Profile> {
212        self.section(role).get(name)
213    }
214
215    /// Whether a (possibly partially broken) profile of this name is declared in its section.
216    pub fn declares(&self, role: Role, name: &str) -> bool {
217        self.file
218            .as_ref()
219            .and_then(|f| f.value.get(role.section()))
220            .is_some_and(|s| s.get(name).is_some())
221    }
222
223    /// Whether `name` means the built-in local destination (no `local` profile is defined).
224    pub fn is_builtin_local(&self, name: &str) -> bool {
225        name == LOCAL_TYPE && !self.destinations.contains_key(name)
226    }
227
228    /// Best-effort line of a profile's name in the file.
229    pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
230        let f = self.file.as_ref()?;
231        f.line_of(name, f.line_of(role.section(), None))
232    }
233
234    /// The target a profile uses for `target` (or its own default target).
235    pub fn target(&self, role: Role, profile: &str, target: Option<&str>) -> Option<(&str, &ProfileTarget)> {
236        let p = self.get(role, profile)?;
237        let t = target.unwrap_or(&p.target);
238        p.targets.get_key_value(t).map(|(k, o)| (k.as_str(), o))
239    }
240}
241
242fn parse_profile(
243    role: Role,
244    name: &str,
245    v: &Value,
246    yf: &YamlFile,
247    section_line: Option<usize>,
248    diags: &mut Diagnostics,
249) -> Option<Profile> {
250    let file = Some(yf.display.clone());
251    let line = yf.line_of(name, section_line);
252    let what = format!("{} profile `{name}`", role.as_str());
253    let Some(m) = v.as_mapping() else {
254        diags.error(
255            "invalid-profile",
256            file,
257            line,
258            format!("{what} must be a map with `target` and `targets`"),
259        );
260        return None;
261    };
262    let target = m.get("target").and_then(Value::as_str);
263    let targets = m.get("targets").and_then(Value::as_mapping);
264    let (Some(target), Some(targets)) = (target, targets) else {
265        let hint = if m.contains_key("outputs") {
266            " (`outputs:` is now `targets:`)"
267        } else {
268            ""
269        };
270        diags.error(
271            "invalid-profile",
272            file,
273            line,
274            format!("{what} needs a `target` and a map of named `targets`{hint}"),
275        );
276        return None;
277    };
278    let mut parsed = BTreeMap::new();
279    let mut ok = true;
280    for (k, o) in targets {
281        let Some(tname) = k.as_str() else { continue };
282        let kind = o.get("type").and_then(Value::as_str);
283        let Some(kind) = kind else {
284            diags.error(
285                "invalid-profile",
286                file.clone(),
287                yf.line_of(tname, line),
288                format!("target `{tname}` of {what} has no `type`"),
289            );
290            ok = false;
291            continue;
292        };
293        let fields = match serde_json::to_value(o) {
294            Ok(serde_json::Value::Object(mut f)) => {
295                f.remove("type");
296                f
297            }
298            _ => serde_json::Map::new(),
299        };
300        parsed.insert(
301            tname.to_string(),
302            ProfileTarget {
303                kind: kind.to_string(),
304                fields,
305            },
306        );
307    }
308    if !parsed.contains_key(target) && ok {
309        diags.error(
310            "invalid-profile",
311            file,
312            yf.line_of("target", line),
313            format!(
314                "{what} has target `{target}`, which isn't one of its targets ({})",
315                parsed
316                    .keys()
317                    .map(|k| format!("`{k}`"))
318                    .collect::<Vec<_>>()
319                    .join(", ")
320            ),
321        );
322        return None;
323    }
324    ok.then_some(Profile {
325        target: target.to_string(),
326        targets: parsed,
327    })
328}