Skip to main content

greentic_operator/
secrets_setup.rs

1//! SecretsSetup is the single entrypoint for secrets initialization and resolution inside greentic-operator.
2//!
3//! Inputs:
4//! - the bundle root where the operator bundle puts state and the `.greentic/dev/.dev.secrets.env` dev store
5//! - the environment, tenant, and optional team that define canonical secret URIs
6//! - optional seeds documents embedded in the bundle (`seeds.yaml` or `<bundle>/state/seeds.yaml`)
7//!
8//! Guarantees:
9//! - exactly one dev store backend is opened per operator process and owned for the lifetime of SecretsSetup
10//! - every required secret discovered from packs/providers is canonicalized and registered in that store
11//! - missing secrets are seeded either from the documents above or with deterministic placeholders
12//!
13//! Non-goals:
14//! - interactive prompting for secrets or manual overrides
15//! - legacy fallback lookups against other namespaces/storage backends
16//! - implicit provider-specific secret inference beyond the declared canonical URIs
17
18use std::{
19    collections::HashMap,
20    path::{Path, PathBuf},
21};
22
23use anyhow::{Result, anyhow};
24use greentic_secrets_lib::core::Error as SecretError;
25use greentic_secrets_lib::{
26    ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
27};
28use serde_yaml_bw;
29use tracing::{debug, info};
30
31use crate::{
32    dev_store_path, secret_requirements::load_secret_keys_from_pack,
33    secrets_gate::canonical_secret_uri,
34};
35
36pub fn resolve_env(override_env: Option<&str>) -> String {
37    override_env
38        .map(|value| value.to_string())
39        .or_else(|| std::env::var("GREENTIC_ENV").ok())
40        .unwrap_or_else(|| "dev".to_string())
41}
42
43pub struct SecretsSetup {
44    store: DevStore,
45    store_path: PathBuf,
46    env: String,
47    tenant: String,
48    team: Option<String>,
49    seeds: HashMap<String, SeedEntry>,
50}
51
52impl SecretsSetup {
53    pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
54        let store_path = dev_store_path::ensure_path(bundle_root)?;
55        info!(path = %store_path.display(), "secrets: using dev store backend");
56        let store = DevStore::with_path(&store_path).map_err(|err| {
57            anyhow!(
58                "failed to open dev secrets store {}: {err}",
59                store_path.display()
60            )
61        })?;
62        let seeds = load_seed_entries(bundle_root)?;
63        Ok(Self {
64            store,
65            store_path,
66            env: env.to_string(),
67            tenant: tenant.to_string(),
68            team: team.map(|value| value.to_string()),
69            seeds,
70        })
71    }
72
73    pub fn store_path(&self) -> &Path {
74        &self.store_path
75    }
76
77    pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
78        let keys = load_secret_keys_from_pack(pack_path)?;
79        if keys.is_empty() {
80            return Ok(());
81        }
82        let mut missing = Vec::new();
83        for key in keys {
84            let uri = canonical_secret_uri(
85                &self.env,
86                &self.tenant,
87                self.team.as_deref(),
88                provider_id,
89                &key,
90            );
91            debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
92            match self.store.get(&uri).await {
93                Ok(_) => continue,
94                Err(SecretError::NotFound { .. }) => {
95                    let source = if self.seeds.contains_key(&uri) {
96                        "seeds.yaml"
97                    } else {
98                        "placeholder"
99                    };
100                    debug!(uri = %uri, source, "seeding missing secret");
101                    missing.push(
102                        self.seeds
103                            .get(&uri)
104                            .cloned()
105                            .unwrap_or_else(|| placeholder_entry(uri.clone())),
106                    );
107                }
108                Err(err) => {
109                    return Err(anyhow!("failed to read secret {}: {err}", uri));
110                }
111            }
112        }
113        if missing.is_empty() {
114            return Ok(());
115        }
116        let report = apply_seed(
117            &self.store,
118            &SeedDoc { entries: missing },
119            ApplyOptions::default(),
120        )
121        .await;
122        if !report.failed.is_empty() {
123            return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
124        }
125        Ok(())
126    }
127}
128
129fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
130    for candidate in seed_paths(bundle_root) {
131        if candidate.exists() {
132            let contents = std::fs::read_to_string(&candidate)?;
133            let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
134            return Ok(doc
135                .entries
136                .into_iter()
137                .map(|entry| (entry.uri.clone(), entry))
138                .collect());
139        }
140    }
141    Ok(HashMap::new())
142}
143
144fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
145    [
146        bundle_root.join("seeds.yaml"),
147        bundle_root.join("state").join("seeds.yaml"),
148    ]
149}
150
151fn placeholder_entry(uri: String) -> SeedEntry {
152    SeedEntry {
153        uri: uri.clone(),
154        format: SecretFormat::Text,
155        value: SeedValue::Text {
156            text: format!("placeholder for {uri}"),
157        },
158        description: Some("auto-applied placeholder".to_string()),
159    }
160}