Skip to main content

greentic_setup/
secrets.rs

1//! Dev secrets store management for bundle setup.
2//!
3//! Provides helpers for locating the dev secrets file and
4//! [`SecretsSetup`] for ensuring pack secrets are seeded.
5
6use 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
18// ── Dev store path helpers ──────────────────────────────────────────────────
19
20const 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
24/// Returns a path explicitly configured via `$GREENTIC_DEV_SECRETS_PATH`.
25pub fn override_path() -> Option<PathBuf> {
26    std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
27}
28
29/// Checks for an existing dev store inside the bundle root.
30pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
31    find_existing_with_override(bundle_root, override_path().as_deref())
32}
33
34/// Looks for an existing dev store using an override path before consulting default candidates.
35pub 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
49/// Ensures the default dev store path exists (creating parent directories) before returning it.
50pub 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
60/// Returns the default dev store path without creating anything.
61pub 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
79// ── SecretsSetup ────────────────────────────────────────────────────────────
80
81/// Single entry-point for secrets initialization and resolution.
82///
83/// Opens exactly one dev store per instance and ensures every required secret
84/// discovered from packs is canonicalized and registered.
85pub 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    /// Path to the dev store file on disk.
116    pub fn store_path(&self) -> &Path {
117        &self.store_path
118    }
119
120    /// Reference to the underlying `DevStore`.
121    pub fn store(&self) -> &DevStore {
122        &self.store
123    }
124
125    /// Ensure all required secrets for a pack exist in the dev store.
126    ///
127    /// Reads `assets/secret-requirements.json` from the pack and seeds any
128    /// missing keys from `seeds.yaml` or with a placeholder.
129    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
183// ── Helpers ─────────────────────────────────────────────────────────────────
184
185fn 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
218/// Load secret requirement keys from a `.gtpack` archive.
219///
220/// Tries `assets/secret-requirements.json` first, then falls back to
221/// CBOR manifest extraction.
222pub 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
249/// Open a `DevStore` from a bundle root path (convenience).
250pub 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}