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}