dwara_core/lifecycle/
profiles.rs

1//! Environment profiles (DW-110): dev/staging/prod config overlays.
2//!
3//! A [`ProfileOverlay`] carries a base config plus per-profile config
4//! patches (each patch is a partial [`Gateway`](crate::config::Gateway)
5//! serialized as YAML). [`apply_profile`] merges the selected profile's
6//! patch onto the base config: the patch's collections (listeners,
7//! routes, upstreams, etc.) REPLACE the base's (not append -- a profile
8//! is a full topology overlay, not a delta), and the patch's scalar
9//! fields override the base's when set.
10//!
11//! The profile is selected via the `DWARA_PROFILE` env var (one of
12//! `dev`, `staging`, `prod`; case-insensitive). When unset, no overlay
13//! is applied (the base config is used as-is).
14//!
15//! The config schema type ([`ProfileOverlay`]) lives in
16//! [`crate::config`] (always present, so configs round-trip without
17//! the `api_lifecycle` feature). This module re-exports it as the
18//! runtime-facing alias the overlay applier consumes.
19
20use serde_json::Value;
21
22pub use crate::config::LifecycleProfilesConfig as ProfileOverlay;
23
24/// The environment profile enum (dev/staging/prod).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum EnvProfile {
28    Dev,
29    Staging,
30    Prod,
31}
32
33impl EnvProfile {
34    /// The config key for this profile (matches the `profiles` map
35    /// keys: `dev`, `staging`, `prod`).
36    pub fn key(self) -> &'static str {
37        match self {
38            EnvProfile::Dev => "dev",
39            EnvProfile::Staging => "staging",
40            EnvProfile::Prod => "prod",
41        }
42    }
43
44    /// Parse a profile from the `DWARA_PROFILE` env var value
45    /// (case-insensitive). Returns `None` when the value is empty or
46    /// not a recognized profile name.
47    pub fn from_env_var(value: &str) -> Option<Self> {
48        match value.trim().to_lowercase().as_str() {
49            "" => None,
50            "dev" => Some(EnvProfile::Dev),
51            "staging" => Some(EnvProfile::Staging),
52            "prod" => Some(EnvProfile::Prod),
53            _ => None,
54        }
55    }
56}
57
58/// The result of applying a profile overlay: the merged config as a
59/// YAML string, plus the profile that was selected (or `None` when no
60/// profile was selected).
61#[derive(Debug, Clone, PartialEq)]
62pub struct AppliedProfile {
63    pub config_yaml: String,
64    pub profile: Option<EnvProfile>,
65}
66
67/// Apply the selected profile's patch onto the base config.
68///
69/// The profile is read from the `DWARA_PROFILE` env var. When unset,
70/// the base config is returned unchanged. When set to a recognized
71/// profile (`dev`/`staging`/`prod`, case-insensitive) AND that profile
72/// has a patch in the overlay, the patch is merged onto the base. When
73/// set to a recognized profile with NO patch, the base is returned
74/// unchanged (the profile is selected but defines no overrides -- a
75/// no-op overlay). When set to an unrecognized value, the base is
76/// returned unchanged (an unknown profile is a no-op, not an error --
77/// the operator may have a typo, but the gateway still starts on the
78/// base config).
79///
80/// The merge is a shallow JSON merge: the base and patch are each
81/// parsed into JSON values, the patch's top-level keys overwrite the
82/// base's, and the result is serialized back to YAML. Collection
83/// fields (listeners, routes, upstreams, etc.) in the patch REPLACE
84/// the base's collections (not append) -- a profile is a full topology
85/// overlay.
86pub fn apply_profile(overlay: &ProfileOverlay) -> Result<AppliedProfile, String> {
87    let profile = std::env::var("DWARA_PROFILE")
88        .ok()
89        .and_then(|v| EnvProfile::from_env_var(&v));
90    let Some(profile) = profile else {
91        return Ok(AppliedProfile {
92            config_yaml: overlay.base_config.clone(),
93            profile: None,
94        });
95    };
96    let Some(patch_yaml) = overlay.profile_overrides.get(profile.key()) else {
97        return Ok(AppliedProfile {
98            config_yaml: overlay.base_config.clone(),
99            profile: Some(profile),
100        });
101    };
102    let merged = merge_yaml(&overlay.base_config, patch_yaml)?;
103    Ok(AppliedProfile {
104        config_yaml: merged,
105        profile: Some(profile),
106    })
107}
108
109/// Merge a patch YAML onto a base YAML: shallow top-level key merge
110/// (patch keys overwrite base keys; keys only in the base are
111/// preserved). Returns the merged config as a YAML string.
112fn merge_yaml(base_yaml: &str, patch_yaml: &str) -> Result<String, String> {
113    let base: Value =
114        serde_yaml_ng::from_str(base_yaml).map_err(|e| format!("base config parse failed: {e}"))?;
115    let patch: Value = serde_yaml_ng::from_str(patch_yaml)
116        .map_err(|e| format!("profile patch parse failed: {e}"))?;
117    let merged = merge_json(&base, &patch);
118    // Serialize back through serde_json -> serde_yaml_ng to get stable
119    // YAML output (serde_yaml_ng's own serializer on a serde_json::Value
120    // round-trips cleanly).
121    serde_yaml_ng::to_string(&merged).map_err(|e| format!("merged serialize failed: {e}"))
122}
123
124/// Shallow JSON merge: patch's top-level keys overwrite base's; keys
125/// only in the base are preserved. Non-object bases or patches are
126/// returned as the patch (the patch wins outright when either side is
127/// not an object).
128fn merge_json(base: &Value, patch: &Value) -> Value {
129    match (base, patch) {
130        (Value::Object(base_map), Value::Object(patch_map)) => {
131            let mut out = base_map.clone();
132            for (k, v) in patch_map {
133                out.insert(k.clone(), v.clone());
134            }
135            Value::Object(out)
136        }
137        _ => patch.clone(),
138    }
139}