Skip to content

Commit 446ba4b

Browse files
Implement profile inheritance
1 parent 04a05eb commit 446ba4b

8 files changed

+880
-92
lines changed

.env-select.toml

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
[applications.server.profiles.base]
2+
variables = {PROTOCOL = "https"}
13
[applications.server.profiles.dev]
4+
extends = ["base"]
25
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
36
[applications.server.profiles.prd]
7+
extends = ["base"]
48
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
59

6-
[applications.empty]
7-
810
# TODO move into tests/ directory
911
[applications.integration-tests.profiles.p1.variables]
1012
VARIABLE1 = "abc"

Cargo.lock

+23
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ toml = {version = "0.7.5", features = ["preserve_order"]}
2929

3030
[dev-dependencies]
3131
assert_cmd = {version = "2.0.11", default-features = false, features = ["color-auto"]}
32+
pretty_assertions = "1.4.0"
3233
rstest = {version = "0.17.0", default-features = false}
3334
rstest_reuse = "0.5.0"
3435
serde_test = "1.0.165"

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Easily switch between predefined values for arbitrary environment variables Feat
99
- Cascading config system, allowing for system and repo-level value definitions
1010
- Grab values dynamically via local commands or Kubernetes pods
1111
- Modify your shell environment with `es set`, or run a one-off command in a modified environment with `es run`
12+
- Re-use common variables between profiles with inheritance
1213

1314
## Table of Contents
1415

USAGE.md

+69
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,75 @@ variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
250250

251251
To see where env-select is loading configs from, and how they are being merged together, run the command with the `--verbose` (or `-v`) flag.
252252

253+
## Profile Inheritance
254+
255+
In addition to top-level merging of multiple config files, env-select also supports inheritance between profiles, via the `extends` field on a profile. For example:
256+
257+
```toml
258+
[applications.server.profiles.base]
259+
variables = {PROTOCOL = "https"}
260+
[applications.server.profiles.dev]
261+
extends = ["base"]
262+
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
263+
[applications.server.profiles.prd]
264+
extends = ["base"]
265+
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
266+
```
267+
268+
During execution, env-select will merge each profile with its parent(s):
269+
270+
```sh
271+
> es set server
272+
❯ === base ===
273+
PROTOCOL=https
274+
275+
=== dev ===
276+
SERVICE1=dev
277+
SERVICE2=also-dev
278+
PROTOCOL=https
279+
280+
=== prd ===
281+
SERVICE1=prd
282+
SERVICE2=also-prd
283+
PROTOCOL=https
284+
```
285+
286+
Note the `PROTOCOL` variable is available in `dev` and `prd`. The profile name given in `extends` is assumed to be a profile of the same application. To extend a profile from another application, use the format `application/profile`:
287+
288+
```toml
289+
[applications.common.profiles.base]
290+
variables = {PROTOCOL = "https"}
291+
[applications.server.profiles.dev]
292+
extends = ["common/base"]
293+
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
294+
[applications.server.profiles.prd]
295+
extends = ["common/base"]
296+
variables = {SERVICE1 = "prd", SERVICE2 = "also-prd"}
297+
```
298+
299+
#### Multiple Inheritance and Precedence
300+
301+
Each profile can extend multiple parents. If two parents have conflicting values, the **left-most** parent has precedence:
302+
303+
```toml
304+
[applications.server.profiles.base1]
305+
variables = {PROTOCOL = "https"}
306+
[applications.server.profiles.base2]
307+
variables = {PROTOCOL = "http"}
308+
[applications.server.profiles.dev]
309+
extends = ["base1", "base2"]
310+
variables = {SERVICE1 = "dev", SERVICE2 = "also-dev"}
311+
```
312+
313+
The value from `base1` is used:
314+
315+
```sh
316+
> es run server dev -- printenv PROTOCOL
317+
https
318+
```
319+
320+
Inheritance is applied recursively, meaning you can have arbitrarily large inheritance trees, **as long as there are no cycles**.
321+
253322
## Configuration Reference
254323

255324
### Value Source

src/config/inheritance.rs

+166-51
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,184 @@
11
//! Utilitied related to profile inheritance resolution
22
3-
use crate::config::{Config, Profile, ProfileReference};
4-
use anyhow::anyhow;
5-
use indexmap::IndexSet;
3+
use crate::config::{Config, DisplayKeys, Name, Profile, ProfileReference};
4+
use anyhow::{anyhow, bail};
5+
use indexmap::{IndexMap, IndexSet};
6+
use log::trace;
7+
use std::{collections::HashMap, fmt::Display, hash::Hash};
68

79
impl Config {
8-
/// Resolve `extends` field for all profiles
10+
/// Resolve inheritance for all profiles. Each profile will have its parents
11+
/// (as specified in its `extends` field) merged into it, recursively.
912
pub(super) fn resolve_inheritance(&mut self) -> anyhow::Result<()> {
10-
// Step 1 - Make sure application is set for all profile references
11-
self.qualify_profile_references();
13+
let mut resolver = InheritanceResolver::from_config(self);
14+
resolver.resolve_all()
15+
}
16+
}
17+
18+
struct InheritanceResolver<'a> {
19+
profiles: HashMap<QualifiedReference, &'a mut Profile>,
20+
unresolved: IndexMap<QualifiedReference, IndexSet<QualifiedReference>>,
21+
}
1222

13-
// Profiles we've *started* (and possibly finished) resolving
14-
let mut visited: IndexSet<ProfileReference> = IndexSet::new();
15-
// Profiles we've *finished* resolving
16-
let mut resolved: IndexSet<ProfileReference> = IndexSet::new();
23+
impl<'a> InheritanceResolver<'a> {
24+
fn from_config(config: &'a mut Config) -> Self {
25+
let mut profiles = HashMap::new();
26+
let mut unresolved = IndexMap::new();
1727

18-
// Step 2 - resolve dependency tree
19-
todo!();
28+
// Flatten profiles into a map, keyed by their path. For each profile,
29+
// we'll also track a list of parents that haven't been resolved+merged
30+
// in yet
31+
for (application_name, application) in &mut config.applications {
32+
for (profile_name, profile) in &mut application.profiles {
33+
let reference =
34+
QualifiedReference::new(application_name, profile_name);
35+
// Qualify relative references using the parent application
36+
let parents = QualifiedReference::qualify_all(
37+
application_name,
38+
profile.extends.iter(),
39+
);
2040

21-
// Step 3 - merge configs together
22-
todo!();
41+
// Any profile with parents is deemed unresolved
42+
profiles.insert(reference.clone(), profile);
43+
if !parents.is_empty() {
44+
unresolved.insert(reference, parents);
45+
}
46+
}
47+
}
48+
49+
trace!(
50+
"Detected {} profiles needing inheritance resolution: {}",
51+
unresolved.len(),
52+
unresolved.display_keys()
53+
);
54+
Self {
55+
profiles,
56+
unresolved,
57+
}
58+
}
2359

60+
/// Resolve inheritance for all profiles
61+
fn resolve_all(&mut self) -> anyhow::Result<()> {
62+
// Resolve each profile. A profile has been resolved when its `parents`
63+
// list is empty, so keep going until they're all done
64+
while let Some((reference, parents)) = self.unresolved.pop() {
65+
self.resolve_profile(reference, parents, &mut IndexSet::new())?;
66+
}
2467
Ok(())
2568
}
2669

27-
/// Fully qualify all profile references. It'd be nice to have a different
28-
/// type to enforce the reference is resolved, but it's not worth drilling
29-
/// that all the way down the tree so we'll just do runtime checks later to
30-
/// be safe
31-
fn qualify_profile_references(&mut self) {
32-
// First, go through and
33-
for (application_name, application) in &mut self.applications {
34-
for profile in application.profiles.values_mut() {
35-
// We have to drain the set and rebuild it, since the hashes
36-
// will change
37-
profile.extends = profile
38-
.extends
39-
.drain(..)
40-
.map(|mut reference| {
41-
if reference.application.is_none() {
42-
reference.application =
43-
Some(application_name.clone());
44-
}
45-
reference
46-
})
47-
.collect();
70+
/// Resolve inheritance for a single profile, recursively. This will also
71+
/// resolve its parents, and their parents, and so on.
72+
fn resolve_profile(
73+
&mut self,
74+
reference: QualifiedReference,
75+
parents: IndexSet<QualifiedReference>,
76+
visited: &mut IndexSet<QualifiedReference>,
77+
) -> anyhow::Result<()> {
78+
trace!("Resolving inheritance for profile {reference}");
79+
visited.insert(reference.clone());
80+
81+
for parent in parents {
82+
trace!("Resolving parent {reference} -> {parent}");
83+
84+
// Check for cycles
85+
if visited.contains(&parent) {
86+
bail!("Inheritance cycle detected: {}", display_cycle(visited));
4887
}
88+
89+
// Check if parent needs to be resolved. If parent is an unknown
90+
// path, we'll skip over here and fail down below
91+
if let Some(grandparents) = self.unresolved.remove(&parent) {
92+
// Parent is unresolved - resolve it now
93+
self.resolve_profile(
94+
parent.clone(),
95+
grandparents,
96+
// When we branch, we have to clone the `visited` list so
97+
// it remains linear. This doesn't seem like a good
98+
// solution, and yet it works...
99+
&mut visited.clone(),
100+
)?;
101+
} else {
102+
trace!("{parent} is resolved");
103+
}
104+
105+
// We know parent is resolved now, merge in their values
106+
Self::apply_inheritance(
107+
(*self
108+
.profiles
109+
.get(&parent)
110+
.ok_or_else(|| anyhow!("Unknown profile: {}", parent))?)
111+
.clone(),
112+
self.profiles
113+
.get_mut(&reference)
114+
.ok_or_else(|| anyhow!("Unknown profile: {}", reference))?,
115+
);
49116
}
117+
Ok(())
50118
}
51119

52-
/// Get a profile by reference. This should only be called *after*
53-
/// qualifying all profile references
54-
fn get_profile(
55-
&self,
56-
reference: &ProfileReference,
57-
) -> anyhow::Result<Option<&Profile>> {
58-
let application = reference.application.as_ref().ok_or_else(|| {
59-
anyhow!(
60-
"Unqualified profile reference {:?} during inheritance \
61-
resolution. This is a bug!",
62-
reference
63-
)
64-
})?;
65-
Ok(self.applications.get(application).and_then(|application| {
66-
application.profiles.get(&reference.profile)
67-
}))
120+
/// Merge a parent into a child, i.e. any data in the parent but *not* the
121+
/// child will be added to the child
122+
fn apply_inheritance(parent: Profile, child: &mut Profile) {
123+
// TODO can we use Merge for this? Right now it's parent-prefential,
124+
// but we may change that
125+
for (variable, parent_value) in parent.variables {
126+
// Only insert the parent value if it isn't already in the child
127+
child.variables.entry(variable).or_insert(parent_value);
128+
}
129+
}
130+
}
131+
132+
/// A [ProfileReference] that has been qualified with its application name,
133+
/// such that it is globally unique
134+
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
135+
struct QualifiedReference {
136+
application: Name,
137+
profile: Name,
138+
}
139+
140+
impl QualifiedReference {
141+
fn new(application: &Name, profile: &Name) -> Self {
142+
Self {
143+
application: application.clone(),
144+
profile: profile.clone(),
145+
}
146+
}
147+
148+
/// Qualify all references for a given application. Any relative
149+
/// references will be qualified with the application name
150+
fn qualify_all<'a>(
151+
parent_application: &'a Name,
152+
references: impl IntoIterator<Item = &'a ProfileReference>,
153+
) -> IndexSet<Self> {
154+
references
155+
.into_iter()
156+
.map(|reference| QualifiedReference {
157+
application: reference
158+
.application
159+
.as_ref()
160+
.unwrap_or(parent_application)
161+
.clone(),
162+
profile: reference.profile.clone(),
163+
})
164+
.collect()
165+
}
166+
}
167+
168+
impl Display for QualifiedReference {
169+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170+
write!(f, "{}/{}", self.application, self.profile)
171+
}
172+
}
173+
174+
/// Pretty print a cycle chain
175+
fn display_cycle<T: Display>(nodes: &IndexSet<T>) -> String {
176+
let mut output = String::new();
177+
for node in nodes {
178+
output.push_str(&node.to_string());
179+
output.push_str(" -> ");
68180
}
181+
// Duplicate the first node at the end, to show the cycle
182+
output.push_str(&nodes[0].to_string());
183+
output
69184
}

0 commit comments

Comments
 (0)