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}