1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Result, anyhow};
10use greentic_secrets_lib::core::Error as SecretError;
11use greentic_secrets_lib::{
12 ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
13};
14use tracing::{debug, info};
15
16use crate::canonical_secret_uri;
17
18const STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
21const STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
22const OVERRIDE_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
23
24pub fn override_path() -> Option<PathBuf> {
26 std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
27}
28
29pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
31 find_existing_with_override(bundle_root, override_path().as_deref())
32}
33
34pub fn find_existing_with_override(
36 bundle_root: &Path,
37 override_path: Option<&Path>,
38) -> Option<PathBuf> {
39 if let Some(path) = override_path
40 && path.exists()
41 {
42 return Some(path.to_path_buf());
43 }
44 candidate_paths(bundle_root)
45 .into_iter()
46 .find(|candidate| candidate.exists())
47}
48
49pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
51 if let Some(path) = override_path() {
52 ensure_parent(&path)?;
53 return Ok(path);
54 }
55 let path = bundle_root.join(STORE_RELATIVE);
56 ensure_parent(&path)?;
57 Ok(path)
58}
59
60pub fn default_path(bundle_root: &Path) -> PathBuf {
62 bundle_root.join(STORE_RELATIVE)
63}
64
65fn candidate_paths(bundle_root: &Path) -> [PathBuf; 2] {
66 [
67 bundle_root.join(STORE_RELATIVE),
68 bundle_root.join(STORE_STATE_RELATIVE),
69 ]
70}
71
72fn ensure_parent(path: &Path) -> Result<()> {
73 if let Some(parent) = path.parent() {
74 std::fs::create_dir_all(parent)?;
75 }
76 Ok(())
77}
78
79pub struct SecretsSetup {
86 store: DevStore,
87 store_path: PathBuf,
88 env: String,
89 tenant: String,
90 team: Option<String>,
91 seeds: HashMap<String, SeedEntry>,
92}
93
94impl SecretsSetup {
95 pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
96 let store_path = ensure_path(bundle_root)?;
97 info!(path = %store_path.display(), "secrets: using dev store backend");
98 let store = DevStore::with_path(&store_path).map_err(|err| {
99 anyhow!(
100 "failed to open dev secrets store {}: {err}",
101 store_path.display()
102 )
103 })?;
104 let seeds = load_seed_entries(bundle_root)?;
105 Ok(Self {
106 store,
107 store_path,
108 env: env.to_string(),
109 tenant: tenant.to_string(),
110 team: team.map(|v| v.to_string()),
111 seeds,
112 })
113 }
114
115 pub fn store_path(&self) -> &Path {
117 &self.store_path
118 }
119
120 pub fn store(&self) -> &DevStore {
122 &self.store
123 }
124
125 pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
130 let keys = load_secret_keys_from_pack(pack_path)?;
131 if keys.is_empty() {
132 return Ok(());
133 }
134
135 let mut missing = Vec::new();
136 for key in keys {
137 let uri = canonical_secret_uri(
138 &self.env,
139 &self.tenant,
140 self.team.as_deref(),
141 provider_id,
142 &key,
143 );
144 debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
145 match self.store.get(&uri).await {
146 Ok(_) => continue,
147 Err(SecretError::NotFound { .. }) => {
148 let source = if self.seeds.contains_key(&uri) {
149 "seeds.yaml"
150 } else {
151 "placeholder"
152 };
153 debug!(uri = %uri, source, "seeding missing secret");
154 missing.push(
155 self.seeds
156 .get(&uri)
157 .cloned()
158 .unwrap_or_else(|| placeholder_entry(uri)),
159 );
160 }
161 Err(err) => {
162 return Err(anyhow!("failed to read secret {uri}: {err}"));
163 }
164 }
165 }
166
167 if missing.is_empty() {
168 return Ok(());
169 }
170 let report = apply_seed(
171 &self.store,
172 &SeedDoc { entries: missing },
173 ApplyOptions::default(),
174 )
175 .await;
176 if !report.failed.is_empty() {
177 return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
178 }
179 Ok(())
180 }
181}
182
183fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
186 for candidate in seed_paths(bundle_root) {
187 if candidate.exists() {
188 let contents = std::fs::read_to_string(&candidate)?;
189 let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
190 return Ok(doc
191 .entries
192 .into_iter()
193 .map(|entry| (entry.uri.clone(), entry))
194 .collect());
195 }
196 }
197 Ok(HashMap::new())
198}
199
200fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
201 [
202 bundle_root.join("seeds.yaml"),
203 bundle_root.join("state").join("seeds.yaml"),
204 ]
205}
206
207fn placeholder_entry(uri: String) -> SeedEntry {
208 SeedEntry {
209 uri: uri.clone(),
210 format: SecretFormat::Text,
211 value: SeedValue::Text {
212 text: format!("placeholder for {uri}"),
213 },
214 description: Some("auto-applied placeholder".to_string()),
215 }
216}
217
218pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
223 let file = std::fs::File::open(pack_path)?;
224 let mut archive = zip::ZipArchive::new(file)?;
225
226 for entry_name in &[
227 "assets/secret-requirements.json",
228 "assets/secret_requirements.json",
229 "secret-requirements.json",
230 "secret_requirements.json",
231 ] {
232 match archive.by_name(entry_name) {
233 Ok(reader) => {
234 let reqs: Vec<SecretRequirement> = serde_json::from_reader(reader)?;
235 return Ok(reqs.into_iter().map(|r| r.key).collect());
236 }
237 Err(zip::result::ZipError::FileNotFound) => continue,
238 Err(err) => return Err(err.into()),
239 }
240 }
241 Ok(vec![])
242}
243
244#[derive(serde::Deserialize)]
245struct SecretRequirement {
246 key: String,
247}
248
249pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
251 let store_path = ensure_path(bundle_root)?;
252 DevStore::with_path(&store_path).map_err(|err| {
253 anyhow!(
254 "failed to open dev secrets store {}: {err}",
255 store_path.display()
256 )
257 })
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use std::io::Write;
264 use zip::write::SimpleFileOptions;
265
266 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
267 let file = std::fs::File::create(path)?;
268 let mut zip = zip::ZipWriter::new(file);
269 zip.start_file(
270 "assets/secret-requirements.json",
271 SimpleFileOptions::default(),
272 )?;
273 zip.write_all(req_json.as_bytes())?;
274 zip.finish()?;
275 Ok(())
276 }
277
278 #[test]
279 fn ensure_path_creates_parent_directories() {
280 let temp = tempfile::tempdir().expect("tempdir");
281 let bundle = temp.path().join("bundle");
282 std::fs::create_dir_all(&bundle).expect("bundle dir");
283 let path = ensure_path(&bundle).expect("ensure path");
284 assert!(path.ends_with(".greentic/dev/.dev.secrets.env"));
285 assert!(path.parent().expect("parent").exists());
286 }
287
288 #[test]
289 fn find_existing_with_override_prefers_override() {
290 let temp = tempfile::tempdir().expect("tempdir");
291 let bundle = temp.path().join("bundle");
292 std::fs::create_dir_all(&bundle).expect("bundle dir");
293 let override_file = temp.path().join("custom.env");
294 std::fs::write(&override_file, "KEY=value\n").expect("write override");
295
296 let found = find_existing_with_override(&bundle, Some(&override_file));
297 assert_eq!(found.as_deref(), Some(override_file.as_path()));
298 }
299
300 #[test]
301 fn find_existing_finds_default_locations() {
302 let temp = tempfile::tempdir().expect("tempdir");
303 let bundle = temp.path().join("bundle");
304 let store_path = bundle.join(STORE_RELATIVE);
305 std::fs::create_dir_all(store_path.parent().expect("parent")).expect("create dirs");
306 std::fs::write(&store_path, "K=V\n").expect("write store");
307
308 let found = find_existing_with_override(&bundle, None).expect("found");
309 assert_eq!(found, store_path);
310 }
311
312 #[test]
313 fn load_secret_keys_from_pack_reads_requirements() {
314 let temp = tempfile::tempdir().expect("tempdir");
315 let pack = temp.path().join("provider.gtpack");
316 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
317 .expect("write pack");
318
319 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
320 assert_eq!(
321 keys,
322 vec!["BOT_TOKEN".to_string(), "API_SECRET".to_string()]
323 );
324 }
325
326 #[test]
327 fn load_secret_keys_from_pack_returns_empty_without_requirements() {
328 let temp = tempfile::tempdir().expect("tempdir");
329 let pack = temp.path().join("provider.gtpack");
330 let file = std::fs::File::create(&pack).expect("create pack");
331 let mut zip = zip::ZipWriter::new(file);
332 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
333 .expect("start entry");
334 zip.write_all(b"questions: []\n").expect("write setup");
335 zip.finish().expect("finish zip");
336
337 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
338 assert!(keys.is_empty());
339 }
340
341 #[tokio::test]
342 async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
343 let temp = tempfile::tempdir().expect("tempdir");
344 let bundle = temp.path().join("bundle");
345 std::fs::create_dir_all(&bundle).expect("bundle dir");
346 let pack = temp.path().join("provider.gtpack");
347 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
348
349 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
350 setup
351 .ensure_pack_secrets(&pack, "messaging-telegram")
352 .await
353 .expect("ensure secrets");
354
355 let uri = canonical_secret_uri(
356 "dev",
357 "tenant-a",
358 Some("core"),
359 "messaging-telegram",
360 "BOT_TOKEN",
361 );
362 let value = setup.store().get(&uri).await.expect("seeded value");
363 let value = String::from_utf8(value).expect("utf8");
364 assert!(
365 value.contains("placeholder for secrets://"),
366 "unexpected placeholder value: {value}"
367 );
368 }
369
370 #[tokio::test]
371 async fn ensure_pack_secrets_uses_seed_values_when_available() {
372 let temp = tempfile::tempdir().expect("tempdir");
373 let bundle = temp.path().join("bundle");
374 std::fs::create_dir_all(&bundle).expect("bundle dir");
375 let seed_uri = canonical_secret_uri(
376 "dev",
377 "tenant-a",
378 Some("core"),
379 "messaging-telegram",
380 "BOT_TOKEN",
381 );
382 let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
383 entries: vec![SeedEntry {
384 uri: seed_uri.clone(),
385 format: SecretFormat::Text,
386 value: SeedValue::Text {
387 text: "seeded-secret".to_string(),
388 },
389 description: Some("test seed".to_string()),
390 }],
391 })
392 .expect("serialize seeds");
393 std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
394
395 let pack = temp.path().join("provider.gtpack");
396 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
397
398 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
399 setup
400 .ensure_pack_secrets(&pack, "messaging-telegram")
401 .await
402 .expect("ensure secrets");
403
404 let value = setup.store().get(&seed_uri).await.expect("seeded value");
405 let value = String::from_utf8(value).expect("utf8");
406 assert_eq!(value, "seeded-secret");
407 }
408}