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#[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
94fn 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_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 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 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
217fn 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}