Skip to main content

greentic_setup/engine/
answers.rs

1//! Answers handling for the setup engine.
2//!
3//! Contains functions for emitting, loading, encrypting, and prompting
4//! for setup answers.
5
6use std::path::Path;
7
8use anyhow::{Context, anyhow};
9use serde_json::{Map as JsonMap, Value};
10
11use crate::plan::SetupPlan;
12use crate::platform_setup::load_effective_static_routes_defaults;
13use crate::{answers_crypto, discovery, setup_input};
14
15use super::plan_builders::infer_default_value;
16use super::types::{LoadedAnswers, SetupConfig};
17
18/// Emit an answers template JSON file.
19///
20/// Discovers all packs in the bundle and generates a template with all
21/// setup questions. Users fill this in and pass it via `--answers`.
22pub fn emit_answers(
23    config: &SetupConfig,
24    plan: &SetupPlan,
25    output_path: &Path,
26    key: Option<&str>,
27    interactive: bool,
28) -> anyhow::Result<()> {
29    let bundle = &plan.bundle;
30
31    // Build the answers document structure
32    let mut answers_doc = serde_json::json!({
33        "greentic_setup_version": "1.0.0",
34        "bundle_source": bundle.display().to_string(),
35        "tenant": config.tenant,
36        "team": config.team,
37        "env": config.env,
38        "platform_setup": {
39            "static_routes": plan.metadata.static_routes.to_answers(),
40            "deployment_targets": plan.metadata.deployment_targets
41        },
42        "setup_answers": {}
43    });
44
45    if !plan.metadata.static_routes.public_web_enabled
46        && plan.metadata.static_routes.public_base_url.is_none()
47        && let Some(existing) =
48            load_effective_static_routes_defaults(bundle, &config.tenant, config.team.as_deref())?
49    {
50        answers_doc["platform_setup"]["static_routes"] =
51            serde_json::to_value(existing.to_answers())?;
52    }
53
54    // Discover packs and extract their QA specs
55    let setup_answers = answers_doc
56        .get_mut("setup_answers")
57        .and_then(|v| v.as_object_mut())
58        .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
59
60    // Add existing answers from the plan metadata
61    for (provider_id, answers) in &plan.metadata.setup_answers {
62        setup_answers.insert(provider_id.clone(), answers.clone());
63    }
64
65    // Discover packs and populate question templates for all providers.
66    // If a provider entry already exists but is empty, merge in the
67    // questions from setup.yaml so the user sees what needs to be filled.
68    if bundle.exists() {
69        let discovered = discovery::discover(bundle)?;
70        for provider in discovered.providers {
71            let provider_id = provider.provider_id.clone();
72            let existing_is_empty = setup_answers
73                .get(&provider_id)
74                .and_then(|v| v.as_object())
75                .is_some_and(|m| m.is_empty());
76            if !setup_answers.contains_key(&provider_id) || existing_is_empty {
77                // Load the setup spec from the pack and create template
78                let template =
79                    if let Some(spec) = setup_input::load_setup_spec(&provider.pack_path)? {
80                        // Pack has setup.yaml - extract questions
81                        let mut entries = JsonMap::new();
82                        for question in &spec.questions {
83                            let default_value = infer_default_value(question);
84                            entries.insert(question.name.clone(), default_value);
85                        }
86                        entries
87                    } else {
88                        // Pack uses flow-based setup or has no questions
89                        // Add empty entry so user knows pack exists
90                        JsonMap::new()
91                    };
92                setup_answers.insert(provider_id, Value::Object(template));
93            }
94        }
95    }
96
97    // Prompt for secret values if interactive
98    if interactive {
99        prompt_secret_answers(bundle, &mut answers_doc)?;
100    }
101
102    encrypt_secret_answers(bundle, &mut answers_doc, key, interactive)?;
103
104    // Write the answers document to the output path
105    let output_content = serde_json::to_string_pretty(&answers_doc)
106        .context("failed to serialize answers document")?;
107
108    if let Some(parent) = output_path.parent() {
109        std::fs::create_dir_all(parent)
110            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
111    }
112
113    std::fs::write(output_path, output_content)
114        .with_context(|| format!("failed to write answers to: {}", output_path.display()))?;
115
116    println!("Answers template written to: {}", output_path.display());
117    Ok(())
118}
119
120/// Load answers from a JSON/YAML file.
121pub fn load_answers(
122    answers_path: &Path,
123    key: Option<&str>,
124    interactive: bool,
125) -> anyhow::Result<LoadedAnswers> {
126    let raw = setup_input::load_setup_input(answers_path)?;
127    let raw = if answers_crypto::has_encrypted_values(&raw) {
128        let resolved_key = match key {
129            Some(value) => value.to_string(),
130            None if interactive => answers_crypto::prompt_for_key("decrypting answers")?,
131            None => {
132                return Err(anyhow!(
133                    "answers file contains encrypted secret values; rerun with --key or interactive input"
134                ));
135            }
136        };
137        answers_crypto::decrypt_tree(&raw, &resolved_key)?
138    } else {
139        raw
140    };
141    match raw {
142        Value::Object(map) => {
143            fn parse_optional_string(
144                map: &JsonMap<String, Value>,
145                key: &str,
146            ) -> anyhow::Result<Option<String>> {
147                match map.get(key) {
148                    None | Some(Value::Null) => Ok(None),
149                    Some(Value::String(value)) => Ok(Some(value.clone())),
150                    Some(_) => Err(anyhow!("answers field '{key}' must be a string or null")),
151                }
152            }
153
154            let tenant = parse_optional_string(&map, "tenant")?;
155            let team = parse_optional_string(&map, "team")?;
156            let env = parse_optional_string(&map, "env")?;
157
158            let platform_setup = map
159                .get("platform_setup")
160                .cloned()
161                .map(serde_json::from_value)
162                .transpose()
163                .context("parse platform_setup answers")?
164                .unwrap_or_default();
165
166            if let Some(Value::Object(setup_answers)) = map.get("setup_answers") {
167                Ok(LoadedAnswers {
168                    tenant,
169                    team,
170                    env,
171                    platform_setup,
172                    setup_answers: setup_answers.clone(),
173                })
174            } else if map.contains_key("bundle_source")
175                || map.contains_key("tenant")
176                || map.contains_key("team")
177                || map.contains_key("env")
178                || map.contains_key("platform_setup")
179            {
180                Ok(LoadedAnswers {
181                    tenant,
182                    team,
183                    env,
184                    platform_setup,
185                    setup_answers: JsonMap::new(),
186                })
187            } else {
188                Ok(LoadedAnswers {
189                    tenant,
190                    team,
191                    env,
192                    platform_setup,
193                    setup_answers: map,
194                })
195            }
196        }
197        _ => Err(anyhow!("answers file must be a JSON/YAML object")),
198    }
199}
200
201/// Prompt user to fill in secret values interactively.
202///
203/// Discovers all secret questions from packs and prompts user to enter
204/// values using secure/hidden input. Updates the answers_doc in place.
205pub fn prompt_secret_answers(bundle: &Path, answers_doc: &mut Value) -> anyhow::Result<()> {
206    use rpassword::prompt_password;
207    use std::io::{self, Write as _};
208
209    let setup_answers = answers_doc
210        .get_mut("setup_answers")
211        .and_then(Value::as_object_mut)
212        .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
213
214    let discovered = if bundle.exists() {
215        discovery::discover(bundle)?
216    } else {
217        return Ok(());
218    };
219
220    // Collect all secret questions that need prompting
221    let mut secret_questions: Vec<(String, String, String, bool)> = Vec::new(); // (provider_id, field_id, title, required)
222
223    for provider in &discovered.providers {
224        let Some(form_spec) =
225            crate::setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
226        else {
227            continue;
228        };
229
230        let provider_answers = setup_answers
231            .get(&provider.provider_id)
232            .and_then(Value::as_object);
233
234        for question in form_spec.questions {
235            if !question.secret {
236                continue;
237            }
238
239            // Check if already has a non-empty value
240            let has_value = provider_answers
241                .and_then(|m| m.get(&question.id))
242                .is_some_and(|v| !v.is_null() && v.as_str().map(|s| !s.is_empty()).unwrap_or(true));
243
244            if !has_value {
245                secret_questions.push((
246                    provider.provider_id.clone(),
247                    question.id.clone(),
248                    question.title.clone(),
249                    question.required,
250                ));
251            }
252        }
253    }
254
255    if secret_questions.is_empty() {
256        return Ok(());
257    }
258
259    println!();
260    println!("── Secret Values ──");
261    println!("Enter values for secret fields (input is hidden):");
262    println!("(Press Enter to skip optional fields)\n");
263
264    for (provider_id, field_id, title, required) in secret_questions {
265        let display_provider = crate::setup_to_formspec::strip_domain_prefix(&provider_id);
266        let marker = if required {
267            " (required)"
268        } else {
269            " (optional)"
270        };
271
272        print!("  [{display_provider}] {title}{marker}: ");
273        io::stdout().flush()?;
274
275        let input = prompt_password("").unwrap_or_default();
276        let trimmed = input.trim();
277
278        if !trimmed.is_empty() {
279            // Update the answers_doc with the inputted value
280            if let Some(provider_answers) = setup_answers
281                .get_mut(&provider_id)
282                .and_then(Value::as_object_mut)
283            {
284                provider_answers.insert(field_id, Value::String(trimmed.to_string()));
285            }
286        } else if required {
287            println!("    \x1b[33m⚠ Skipped (will need to be filled in later)\x1b[0m");
288        }
289    }
290
291    println!();
292    Ok(())
293}
294
295/// Encrypt secret values in the answers document.
296pub fn encrypt_secret_answers(
297    bundle: &Path,
298    answers_doc: &mut Value,
299    key: Option<&str>,
300    interactive: bool,
301) -> anyhow::Result<()> {
302    let setup_answers = answers_doc
303        .get_mut("setup_answers")
304        .and_then(Value::as_object_mut)
305        .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
306    let discovered = if bundle.exists() {
307        discovery::discover(bundle)?
308    } else {
309        return Ok(());
310    };
311
312    let mut secret_paths = Vec::new();
313    for provider in discovered.providers {
314        let Some(form_spec) =
315            crate::setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
316        else {
317            continue;
318        };
319        let Some(provider_answers) = setup_answers
320            .get_mut(&provider.provider_id)
321            .and_then(Value::as_object_mut)
322        else {
323            continue;
324        };
325        for question in form_spec.questions {
326            if !question.secret {
327                continue;
328            }
329            let Some(value) = provider_answers.get(&question.id).cloned() else {
330                continue;
331            };
332            if value.is_null() || value == Value::String(String::new()) {
333                continue;
334            }
335            secret_paths.push((provider.provider_id.clone(), question.id.clone(), value));
336        }
337    }
338
339    if secret_paths.is_empty() {
340        return Ok(());
341    }
342
343    let resolved_key = match key {
344        Some(value) => value.to_string(),
345        None if interactive => answers_crypto::prompt_for_key("encrypting answers")?,
346        None => {
347            return Err(anyhow!(
348                "answer document includes secret values; rerun with --key or interactive input"
349            ));
350        }
351    };
352
353    for (provider_id, field_id, value) in secret_paths {
354        let encrypted = answers_crypto::encrypt_value(&value, &resolved_key)?;
355        if let Some(provider_answers) = setup_answers
356            .get_mut(&provider_id)
357            .and_then(Value::as_object_mut)
358        {
359            provider_answers.insert(field_id, encrypted);
360        }
361    }
362
363    Ok(())
364}