Skip to main content

greentic_bundle/setup/
persist.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use anyhow::{Context, Result, bail};
5use greentic_deploy_spec::SecretRef;
6use serde_json::Value;
7
8use super::backend::SetupBackend;
9use super::legacy_formspec::normalize_answer_value;
10use super::{
11    FormSpec, PersistedSetupState, QuestionSpec, SETUP_STATE_DIR, SETUP_STATE_SCHEMA_VERSION,
12    SetupSpecInput,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SetupInstruction {
17    pub provider_id: String,
18    pub spec_input: SetupSpecInput,
19    pub answers: Value,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SetupPersistenceResult {
24    pub states: Vec<PersistedSetupState>,
25    pub writes: Vec<String>,
26}
27
28/// Env + bundle scope used to mint
29/// `secret://<env>/<bundle>/<provider_id>/<question_id>` references for
30/// secret-marked answers (B12, plan §246 app-bundle form). `provider_id` is
31/// part of the path so two providers in one bundle can carry the same secret
32/// key (e.g. `api_token`) without colliding on the same ref.
33#[derive(Debug, Clone, Copy)]
34pub struct SetupScope<'a> {
35    pub env_id: &'a str,
36    pub bundle_id: &'a str,
37}
38
39pub fn collect_setup_instructions(
40    specs: &BTreeMap<String, Value>,
41    answers: &BTreeMap<String, Value>,
42) -> Result<Vec<SetupInstruction>> {
43    let mut provider_ids = specs.keys().cloned().collect::<Vec<_>>();
44    provider_ids.sort();
45    provider_ids.dedup();
46
47    let mut instructions = Vec::new();
48    for provider_id in provider_ids {
49        let spec_input = parse_spec_input(
50            specs
51                .get(&provider_id)
52                .cloned()
53                .ok_or_else(|| anyhow::anyhow!("missing setup spec for {provider_id}"))?,
54        )?;
55        let provider_answers = answers
56            .get(&provider_id)
57            .cloned()
58            .unwrap_or_else(|| Value::Object(Default::default()));
59        instructions.push(SetupInstruction {
60            provider_id,
61            spec_input,
62            answers: provider_answers,
63        });
64    }
65    Ok(instructions)
66}
67
68pub fn persist_setup(
69    root: &Path,
70    instructions: &[SetupInstruction],
71    backend: &dyn SetupBackend,
72    scope: &SetupScope<'_>,
73) -> Result<SetupPersistenceResult> {
74    let mut states = Vec::new();
75    let mut writes = Vec::new();
76    for instruction in instructions {
77        let state = build_state(instruction, scope)?;
78        reject_env_id_remint(root, &state)?;
79        if let Some(path) = backend.persist(&state)? {
80            writes.push(relative_display(root, &path));
81        } else {
82            writes.push(format!(
83                "{SETUP_STATE_DIR}/{}.json",
84                instruction.provider_id
85            ));
86        }
87        states.push(state);
88    }
89    writes.sort();
90    writes.dedup();
91    Ok(SetupPersistenceResult { states, writes })
92}
93
94/// Reject re-minting an existing state under a different env (C7).
95///
96/// Re-running the wizard for the same `(bundle, provider)` under a new
97/// `--env` would aliase two envs' `secret://` refs onto the same provider
98/// path — the on-disk URI now reads as one env, while the in-memory scope
99/// (and live DevStore writes) point at another. Detect at the persist
100/// boundary by reading whatever's on disk and comparing `env_id`. Older
101/// `schema_version <= 2` states have no `env_id` and are likewise rejected:
102/// they were minted before C7's binding existed, so the conservative answer
103/// is to require a fresh emission.
104fn reject_env_id_remint(root: &Path, state: &PersistedSetupState) -> Result<()> {
105    let path = root
106        .join(SETUP_STATE_DIR)
107        .join(format!("{}.json", state.provider_id));
108    let Ok(bytes) = std::fs::read(&path) else {
109        return Ok(());
110    };
111    let existing: serde_json::Value = match serde_json::from_slice(&bytes) {
112        Ok(v) => v,
113        Err(e) => bail!(
114            "persisted setup state at `{}` is corrupt JSON ({e}), refusing to overwrite (delete the file and re-run the wizard)",
115            path.display()
116        ),
117    };
118    let existing_env = existing.get("env_id").and_then(|v| v.as_str());
119    match existing_env {
120        Some(env) if env == state.env_id => Ok(()),
121        Some(env) => bail!(
122            "persisted setup state at `{}` was minted under env `{env}`, refusing to overwrite under env `{}` (use a different bundle_id to keep both envs)",
123            path.display(),
124            state.env_id
125        ),
126        None => bail!(
127            "persisted setup state at `{}` has no env_id (pre-C7 schema), refusing to overwrite under env `{}` (delete the file and re-run the wizard to mint fresh state)",
128            path.display(),
129            state.env_id
130        ),
131    }
132}
133
134fn build_state(
135    instruction: &SetupInstruction,
136    scope: &SetupScope<'_>,
137) -> Result<PersistedSetupState> {
138    let (source_kind, form) =
139        super::form_spec_from_input(&instruction.spec_input, &instruction.provider_id)?;
140
141    // Validate the three identifiers that flow into the `secret://` URI path.
142    // SecretRef::try_new only enforces scheme + non-empty env segment, so a
143    // `/` or empty value here would silently corrupt the 4-segment layout and
144    // alias unrelated slots. bundle_id is normalized upstream by
145    // normalize_bundle_id but we still validate it for defense-in-depth.
146    validate_ref_segment("env_id", scope.env_id)?;
147    validate_ref_segment("bundle_id", scope.bundle_id)?;
148    validate_ref_segment("provider_id", &instruction.provider_id)?;
149
150    let mut normalized_answers = normalize_answers(&form, &instruction.answers)?;
151    let mut non_secret_config = BTreeMap::new();
152    let mut secret_refs = BTreeMap::new();
153    let mut seen_ids = BTreeSet::new();
154    for question in &form.questions {
155        if !seen_ids.insert(question.id.clone()) {
156            // Duplicate id in the form spec would let the first iteration
157            // remove the answer from normalized_answers (for secrets) and the
158            // second iteration's `.get(...)` would return None — silently
159            // dropping the second question. Fail loudly instead.
160            bail!(
161                "duplicate question id `{}` in setup spec for provider `{}`",
162                question.id,
163                instruction.provider_id
164            );
165        }
166        let Some(value) = normalized_answers.get(&question.id).cloned() else {
167            continue;
168        };
169        if question.secret {
170            // Plaintext never persists in PersistedSetupState. This crate
171            // records only a `secret://` reference; the actual secret bytes
172            // are routed to the env's secrets backend by the *consuming*
173            // wizard pipeline (greentic-setup / greentic-operator qa_persist
174            // → DevStore). NOTE: the two URI schemes are distinct address
175            // spaces — DevStore writes under `secrets://<env>/<tenant>/<team>/
176            // <provider>/<key>`, this crate mints `secret://<env>/<bundle>/
177            // <provider>/<question>`. Phase D wires the resolver that bridges
178            // them (A10's deferred real `SecretsSink`); today the on-disk
179            // state is intent + provenance, not storage.
180            //
181            // Drop the answer from normalized_answers too so the state file
182            // carries zero secret material.
183            validate_ref_segment("question.id", &question.id)?;
184            normalized_answers.remove(&question.id);
185            let uri = format!(
186                "secret://{}/{}/{}/{}",
187                scope.env_id, scope.bundle_id, instruction.provider_id, question.id
188            );
189            let secret_ref = SecretRef::try_new(uri)
190                .with_context(|| format!("mint secret ref for answer {}", question.id))?;
191            secret_refs.insert(question.id.clone(), secret_ref);
192        } else {
193            non_secret_config.insert(question.id.clone(), value);
194        }
195    }
196
197    if !secret_refs.is_empty() {
198        tracing::debug!(
199            provider = %instruction.provider_id,
200            count = secret_refs.len(),
201            "recorded secret refs in setup state — backend population is the consuming wizard pipeline's responsibility (qa_persist → secrets backend)",
202        );
203    }
204
205    Ok(PersistedSetupState {
206        schema_version: SETUP_STATE_SCHEMA_VERSION,
207        env_id: scope.env_id.to_string(),
208        provider_id: instruction.provider_id.clone(),
209        source_kind,
210        form,
211        normalized_answers,
212        non_secret_config,
213        secret_refs,
214    })
215}
216
217/// Reject empty or `/`-containing identifiers that would corrupt the
218/// `secret://<env>/<bundle>/<provider>/<question>` path structure
219/// (SecretRef::try_new only validates the scheme + non-empty env segment).
220fn validate_ref_segment(label: &str, value: &str) -> Result<()> {
221    if value.is_empty() {
222        bail!("{label} must not be empty when minting a secret ref");
223    }
224    if value.contains('/') {
225        bail!("{label} `{value}` contains '/' which would corrupt the secret-ref URI path");
226    }
227    Ok(())
228}
229
230fn normalize_answers(form: &FormSpec, answers: &Value) -> Result<BTreeMap<String, Value>> {
231    let map = answers
232        .as_object()
233        .ok_or_else(|| anyhow::anyhow!("setup answers must be a JSON object"))?;
234    let mut normalized = BTreeMap::new();
235    for question in &form.questions {
236        match map.get(&question.id) {
237            Some(value) => {
238                normalized.insert(
239                    question.id.clone(),
240                    normalize_question_answer(question, value)?,
241                );
242            }
243            None if question.required => bail!("missing required setup answer for {}", question.id),
244            None => {
245                if let Some(default) = &question.default_value {
246                    normalized.insert(question.id.clone(), default.clone());
247                }
248            }
249        }
250    }
251    Ok(normalized)
252}
253
254fn normalize_question_answer(question: &QuestionSpec, value: &Value) -> Result<Value> {
255    normalize_answer_value(question, value)
256}
257
258fn parse_spec_input(value: Value) -> Result<SetupSpecInput> {
259    serde_json::from_value(value).map_err(Into::into)
260}
261
262fn relative_display(root: &Path, path: &Path) -> String {
263    path.strip_prefix(root)
264        .unwrap_or(path)
265        .display()
266        .to_string()
267}