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