greentic_operator/
secrets_manager.rs1use std::borrow::Cow;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow};
7
8use crate::operator_log;
9use crate::secrets_backend::{self, SecretsBackendKind};
10
11const OVERRIDE_ENV: &str = "GREENTIC_SECRETS_MANAGER_PACK";
12const DEFAULT_SECRETS_DIR: &str = "providers/secrets";
13
14#[derive(Clone, Debug)]
15pub struct SecretsManagerSelection {
16 pub scope: SelectedKind,
17 pub pack_path: Option<PathBuf>,
18 pub reason: String,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum SelectedKind {
23 TenantTeam,
24 Tenant,
25 Default,
26 Override,
27 None,
28}
29
30impl SecretsManagerSelection {
31 pub fn description(&self) -> String {
32 match &self.pack_path {
33 Some(path) => format!("{} (pack={})", self.reason, path.display()),
34 None => self.reason.clone(),
35 }
36 }
37
38 pub fn kind(&self) -> Result<SecretsBackendKind> {
39 if let Some(pack_path) = &self.pack_path {
40 secrets_backend::backend_kind_from_pack(pack_path)
41 } else {
42 Ok(SecretsBackendKind::DevStore)
43 }
44 }
45}
46
47pub fn canonical_team<'a>(team: Option<&'a str>) -> Cow<'a, str> {
48 match team
49 .map(|value| value.trim())
50 .filter(|trimmed| !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("default"))
51 {
52 Some(value) => Cow::Borrowed(value),
53 None => Cow::Borrowed("_"),
54 }
55}
56
57pub fn select_secrets_manager(
58 bundle_root: &Path,
59 tenant: &str,
60 team: &str,
61) -> Result<SecretsManagerSelection> {
62 if let Some(override_path) = resolve_override(bundle_root)? {
63 return Ok(SecretsManagerSelection {
64 scope: SelectedKind::Override,
65 pack_path: Some(override_path.clone()),
66 reason: format!("override secrets manager pack {}", override_path.display()),
67 });
68 }
69
70 let candidate_dirs = [
71 (
72 SelectedKind::TenantTeam,
73 bundle_root
74 .join(DEFAULT_SECRETS_DIR)
75 .join(tenant)
76 .join(team),
77 ),
78 (
79 SelectedKind::Tenant,
80 bundle_root.join(DEFAULT_SECRETS_DIR).join(tenant),
81 ),
82 (SelectedKind::Default, bundle_root.join(DEFAULT_SECRETS_DIR)),
83 ];
84
85 for (kind, dir) in &candidate_dirs {
86 if let Some(pack) = find_best_pack(dir).context("scan secrets manager packs")? {
87 return Ok(SecretsManagerSelection {
88 scope: *kind,
89 pack_path: Some(pack.clone()),
90 reason: match kind {
91 SelectedKind::TenantTeam => "tenant/team secrets manager pack".to_string(),
92 SelectedKind::Tenant => "tenant secrets manager pack".to_string(),
93 SelectedKind::Default => "default secrets manager pack".to_string(),
94 _ => "secrets manager pack".to_string(),
95 },
96 });
97 }
98 }
99
100 Ok(SecretsManagerSelection {
101 scope: SelectedKind::None,
102 pack_path: None,
103 reason: "no secrets manager pack found".to_string(),
104 })
105}
106
107fn resolve_override(bundle_root: &Path) -> Result<Option<PathBuf>> {
108 let value = match env::var_os(OVERRIDE_ENV) {
109 Some(value) => value,
110 None => return Ok(None),
111 };
112 let candidate = PathBuf::from(value);
113 let resolved = if candidate.is_absolute() {
114 candidate
115 } else {
116 bundle_root.join(candidate)
117 };
118 if !resolved.exists() {
119 return Err(anyhow!(
120 "override secrets manager pack {} not found",
121 resolved.display()
122 ));
123 }
124 Ok(Some(resolved))
125}
126
127fn find_best_pack(dir: &Path) -> Result<Option<PathBuf>> {
128 if !dir.is_dir() {
129 return Ok(None);
130 }
131 let mut packs = Vec::new();
132 for entry in fs::read_dir(dir).with_context(|| format!("read secrets dir {}", dir.display()))? {
133 let entry = entry?;
134 let path = entry.path();
135 if path
136 .extension()
137 .and_then(|ext| ext.to_str())
138 .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
139 .unwrap_or(false)
140 && path.is_file()
141 {
142 packs.push(path);
143 }
144 }
145 if packs.is_empty() {
146 return Ok(None);
147 }
148 packs.sort();
149 if packs.len() > 1 {
150 operator_log::warn(
151 module_path!(),
152 format!(
153 "multiple secrets manager packs found in {}; using {}",
154 dir.display(),
155 packs[0]
156 .file_name()
157 .and_then(|name| name.to_str())
158 .unwrap_or("unknown")
159 ),
160 );
161 }
162 Ok(Some(packs.into_iter().next().unwrap()))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::env;
169 use tempfile::tempdir;
170
171 #[test]
172 fn canonical_team_maps_default_and_empty_to_underscore() {
173 assert_eq!(canonical_team(Some("default")), "_");
174 assert_eq!(canonical_team(Some("")), "_");
175 assert_eq!(canonical_team(Some("team")), "team");
176 }
177
178 #[test]
179 fn selects_tenant_team_over_tenant_and_default() {
180 let dir = tempdir().unwrap();
181 let base = dir.path().join(DEFAULT_SECRETS_DIR);
182 fs::create_dir_all(base.join("tenant").join("team")).unwrap();
183 fs::create_dir_all(base.join("tenant")).unwrap();
184 fs::create_dir_all(&base).unwrap();
185 let team_pack = base.join("tenant").join("team").join("foo.gtpack");
186 fs::write(&team_pack, "").unwrap();
187 let tenant_pack = base.join("tenant").join("bar.gtpack");
188 fs::write(&tenant_pack, "").unwrap();
189 let default_pack = base.join("default.gtpack");
190 fs::write(&default_pack, "").unwrap();
191 let selection = select_secrets_manager(dir.path(), "tenant", "team").unwrap();
192 assert_eq!(selection.scope, SelectedKind::TenantTeam);
193 assert_eq!(
194 selection.pack_path.unwrap().file_name().unwrap(),
195 "foo.gtpack"
196 );
197 }
198
199 #[test]
200 fn override_env_wins() {
201 let _env_guard = crate::test_env_lock().lock().unwrap();
202 let dir = tempdir().unwrap();
203 let alt = dir.path().join("alt.gtpack");
204 fs::write(&alt, "").unwrap();
205 unsafe {
206 env::set_var(OVERRIDE_ENV, alt.strip_prefix(dir.path()).unwrap());
207 }
208 let selection = select_secrets_manager(dir.path(), "tenant", "team").unwrap();
209 unsafe {
210 env::remove_var(OVERRIDE_ENV);
211 }
212 assert_eq!(selection.scope, SelectedKind::Override);
213 assert_eq!(selection.pack_path.unwrap(), alt);
214 }
215}