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 qa_spec::QuestionType;
10use serde_json::{Map as JsonMap, Value};
11
12use crate::plan::SetupPlan;
13use crate::platform_setup::load_effective_static_routes_defaults;
14use crate::{answers_crypto, discovery, setup_input};
15
16use super::plan_builders::infer_default_value;
17use super::types::{LoadedAnswers, SetupConfig};
18
19/// Emit an answers template JSON file.
20///
21/// Discovers all packs in the bundle and generates a template with all
22/// setup questions. Users fill this in and pass it via `--answers`.
23pub fn emit_answers(
24    config: &SetupConfig,
25    plan: &SetupPlan,
26    output_path: &Path,
27    key: Option<&str>,
28    interactive: bool,
29) -> anyhow::Result<()> {
30    let bundle = &plan.bundle;
31
32    // Build the answers document structure.
33    // `platform_setup.tunnel` is emitted as a placeholder so
34    // `--non-interactive --answers` runs don't deadlock on a hidden
35    // tunnel-mode TTY prompt — see complete_loaded_answers_with_prompts.
36    let tunnel_value = match plan.metadata.tunnel.as_ref() {
37        Some(t) => serde_json::to_value(t)?,
38        None => serde_json::json!({ "mode": null }),
39    };
40    let mut answers_doc = serde_json::json!({
41        "greentic_setup_version": "1.0.0",
42        "bundle_source": bundle.display().to_string(),
43        "tenant": config.tenant,
44        "team": config.team,
45        "env": config.env,
46        "platform_setup": {
47            "static_routes": plan.metadata.static_routes.to_answers(),
48            "deployment_targets": plan.metadata.deployment_targets,
49            "tunnel": tunnel_value
50        },
51        "setup_answers": {}
52    });
53
54    if !plan.metadata.static_routes.public_web_enabled
55        && plan.metadata.static_routes.public_base_url.is_none()
56        && let Some(existing) =
57            load_effective_static_routes_defaults(bundle, &config.tenant, config.team.as_deref())?
58    {
59        answers_doc["platform_setup"]["static_routes"] =
60            serde_json::to_value(existing.to_answers())?;
61    }
62
63    // Discover packs and extract their QA specs
64    let setup_answers = answers_doc
65        .get_mut("setup_answers")
66        .and_then(|v| v.as_object_mut())
67        .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
68
69    // Add existing answers from the plan metadata
70    for (provider_id, answers) in &plan.metadata.setup_answers {
71        setup_answers.insert(provider_id.clone(), answers.clone());
72    }
73
74    // Discover packs and populate question templates for all providers.
75    // If a provider entry already exists but is empty, merge in the
76    // questions from setup.yaml so the user sees what needs to be filled.
77    if bundle.exists() {
78        let discovered = discovery::discover(bundle)?;
79        for provider in discovered.setup_targets() {
80            let provider_id = provider.provider_id.clone();
81            let existing_is_empty = setup_answers
82                .get(&provider_id)
83                .and_then(|v| v.as_object())
84                .is_some_and(|m| m.is_empty());
85            if !setup_answers.contains_key(&provider_id) || existing_is_empty {
86                let template = if let Some(form_spec) =
87                    crate::setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider_id)
88                {
89                    template_from_form_spec(&form_spec)
90                } else if let Some(spec) = setup_input::load_setup_spec(&provider.pack_path)? {
91                    let mut entries = JsonMap::new();
92                    for question in &spec.questions {
93                        let default_value = infer_default_value(question);
94                        entries.insert(question.name.clone(), default_value);
95                    }
96                    entries
97                } else {
98                    JsonMap::new()
99                };
100                setup_answers.insert(provider_id, Value::Object(template));
101            }
102        }
103    }
104
105    // Prompt for secret values if interactive
106    if interactive {
107        prompt_secret_answers(bundle, &mut answers_doc)?;
108    }
109
110    encrypt_secret_answers(bundle, &mut answers_doc, key, interactive)?;
111
112    // Write the answers document to the output path
113    let output_content = serde_json::to_string_pretty(&answers_doc)
114        .context("failed to serialize answers document")?;
115
116    if let Some(parent) = output_path.parent() {
117        std::fs::create_dir_all(parent)
118            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
119    }
120
121    std::fs::write(output_path, output_content)
122        .with_context(|| format!("failed to write answers to: {}", output_path.display()))?;
123
124    println!("Answers template written to: {}", output_path.display());
125    Ok(())
126}
127
128/// Load answers from a JSON/YAML file.
129pub fn load_answers(
130    answers_path: &Path,
131    key: Option<&str>,
132    interactive: bool,
133) -> anyhow::Result<LoadedAnswers> {
134    let raw = setup_input::load_setup_input(answers_path)?;
135    let raw = if answers_crypto::has_encrypted_values(&raw) {
136        let resolved_key = match key {
137            Some(value) => value.to_string(),
138            None if interactive => answers_crypto::prompt_for_key("decrypting answers")?,
139            None => {
140                return Err(anyhow!(
141                    "answers file contains encrypted secret values; rerun with --key or interactive input"
142                ));
143            }
144        };
145        answers_crypto::decrypt_tree(&raw, &resolved_key)?
146    } else {
147        raw
148    };
149    match raw {
150        Value::Object(map) => {
151            fn parse_optional_string(
152                map: &JsonMap<String, Value>,
153                key: &str,
154            ) -> anyhow::Result<Option<String>> {
155                match map.get(key) {
156                    None | Some(Value::Null) => Ok(None),
157                    Some(Value::String(value)) => Ok(Some(value.clone())),
158                    Some(_) => Err(anyhow!("answers field '{key}' must be a string or null")),
159                }
160            }
161
162            let tenant = parse_optional_string(&map, "tenant")?;
163            let team = parse_optional_string(&map, "team")?;
164            let env = parse_optional_string(&map, "env")?;
165
166            let platform_setup = map
167                .get("platform_setup")
168                .cloned()
169                .map(serde_json::from_value)
170                .transpose()
171                .context("parse platform_setup answers")?
172                .unwrap_or_default();
173
174            if let Some(Value::Object(setup_answers)) = map.get("setup_answers") {
175                Ok(LoadedAnswers {
176                    tenant,
177                    team,
178                    env,
179                    platform_setup,
180                    setup_answers: setup_answers.clone(),
181                })
182            } else if map.contains_key("bundle_source")
183                || map.contains_key("tenant")
184                || map.contains_key("team")
185                || map.contains_key("env")
186                || map.contains_key("platform_setup")
187            {
188                Ok(LoadedAnswers {
189                    tenant,
190                    team,
191                    env,
192                    platform_setup,
193                    setup_answers: JsonMap::new(),
194                })
195            } else {
196                Ok(LoadedAnswers {
197                    tenant,
198                    team,
199                    env,
200                    platform_setup,
201                    setup_answers: map,
202                })
203            }
204        }
205        _ => Err(anyhow!("answers file must be a JSON/YAML object")),
206    }
207}
208
209/// Prompt user to fill in secret values interactively.
210///
211/// Discovers all secret questions from packs and prompts user to enter
212/// values using secure/hidden input. Updates the answers_doc in place.
213pub fn prompt_secret_answers(bundle: &Path, answers_doc: &mut Value) -> anyhow::Result<()> {
214    use rpassword::prompt_password;
215    use std::io::{self, Write as _};
216
217    let setup_answers = answers_doc
218        .get_mut("setup_answers")
219        .and_then(Value::as_object_mut)
220        .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
221
222    let discovered = if bundle.exists() {
223        discovery::discover(bundle)?
224    } else {
225        return Ok(());
226    };
227
228    // Collect all secret questions that need prompting
229    let mut secret_questions: Vec<(String, String, String, bool)> = Vec::new(); // (provider_id, field_id, title, required)
230
231    for provider in discovered.setup_targets() {
232        let Some(form_spec) =
233            crate::setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
234        else {
235            continue;
236        };
237
238        let provider_answers = setup_answers
239            .get(&provider.provider_id)
240            .and_then(Value::as_object);
241
242        for question in form_spec.questions {
243            if !question.secret {
244                continue;
245            }
246
247            // Check if already has a non-empty value
248            let has_value = provider_answers
249                .and_then(|m| m.get(&question.id))
250                .is_some_and(|v| !v.is_null() && v.as_str().map(|s| !s.is_empty()).unwrap_or(true));
251
252            if !has_value {
253                secret_questions.push((
254                    provider.provider_id.clone(),
255                    question.id.clone(),
256                    question.title.clone(),
257                    question.required,
258                ));
259            }
260        }
261    }
262
263    if secret_questions.is_empty() {
264        return Ok(());
265    }
266
267    println!();
268    println!("── Secret Values ──");
269    println!("Enter values for secret fields (input is hidden):");
270    println!("(Press Enter to skip optional fields)\n");
271
272    for (provider_id, field_id, title, required) in secret_questions {
273        let display_provider = crate::setup_to_formspec::strip_domain_prefix(&provider_id);
274        let marker = if required {
275            " (required)"
276        } else {
277            " (optional)"
278        };
279
280        print!("  [{display_provider}] {title}{marker}: ");
281        io::stdout().flush()?;
282
283        let input = prompt_password("").unwrap_or_default();
284        let trimmed = input.trim();
285
286        if !trimmed.is_empty() {
287            // Update the answers_doc with the inputted value
288            if let Some(provider_answers) = setup_answers
289                .get_mut(&provider_id)
290                .and_then(Value::as_object_mut)
291            {
292                provider_answers.insert(field_id, Value::String(trimmed.to_string()));
293            }
294        } else if required {
295            println!("    \x1b[33m⚠ Skipped (will need to be filled in later)\x1b[0m");
296        }
297    }
298
299    println!();
300    Ok(())
301}
302
303/// Encrypt secret values in the answers document.
304pub fn encrypt_secret_answers(
305    bundle: &Path,
306    answers_doc: &mut Value,
307    key: Option<&str>,
308    interactive: bool,
309) -> anyhow::Result<()> {
310    let setup_answers = answers_doc
311        .get_mut("setup_answers")
312        .and_then(Value::as_object_mut)
313        .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
314    let discovered = if bundle.exists() {
315        discovery::discover(bundle)?
316    } else {
317        return Ok(());
318    };
319
320    let mut secret_paths = Vec::new();
321    for provider in discovered.setup_targets() {
322        let Some(form_spec) =
323            crate::setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
324        else {
325            continue;
326        };
327        let Some(provider_answers) = setup_answers
328            .get_mut(&provider.provider_id)
329            .and_then(Value::as_object_mut)
330        else {
331            continue;
332        };
333        for question in form_spec.questions {
334            if !question.secret {
335                continue;
336            }
337            let Some(value) = provider_answers.get(&question.id).cloned() else {
338                continue;
339            };
340            if value.is_null() || value == Value::String(String::new()) {
341                continue;
342            }
343            secret_paths.push((provider.provider_id.clone(), question.id.clone(), value));
344        }
345    }
346
347    if secret_paths.is_empty() {
348        return Ok(());
349    }
350
351    let resolved_key = match key {
352        Some(value) => value.to_string(),
353        None if interactive => answers_crypto::prompt_for_key("encrypting answers")?,
354        None => {
355            return Err(anyhow!(
356                "answer document includes secret values; rerun with --key or interactive input"
357            ));
358        }
359    };
360
361    for (provider_id, field_id, value) in secret_paths {
362        let encrypted = answers_crypto::encrypt_value(&value, &resolved_key)?;
363        if let Some(provider_answers) = setup_answers
364            .get_mut(&provider_id)
365            .and_then(Value::as_object_mut)
366        {
367            provider_answers.insert(field_id, encrypted);
368        }
369    }
370
371    Ok(())
372}
373
374fn template_from_form_spec(form_spec: &qa_spec::FormSpec) -> JsonMap<String, Value> {
375    let mut entries = JsonMap::new();
376    for question in &form_spec.questions {
377        let value = question
378            .default_value
379            .as_ref()
380            .map(|default| crate::qa::prompts::parse_typed_value(question.kind, default))
381            .unwrap_or_else(|| empty_value_for_question(question.kind));
382        entries.insert(question.id.clone(), value);
383    }
384    entries
385}
386
387fn empty_value_for_question(kind: QuestionType) -> Value {
388    match kind {
389        QuestionType::Boolean => Value::String(String::new()),
390        QuestionType::Number => Value::String(String::new()),
391        _ => Value::String(String::new()),
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::engine::{SetupConfig, SetupEngine, SetupRequest};
399    use crate::plan::TenantSelection;
400    use crate::platform_setup::StaticRoutesPolicy;
401    use std::collections::BTreeSet;
402    use std::io::Write;
403    use zip::write::{FileOptions, ZipWriter};
404
405    fn write_app_pack(path: &Path, pack_id: &str, secret_key: &str) -> anyhow::Result<()> {
406        let file = std::fs::File::create(path)?;
407        let mut writer = ZipWriter::new(file);
408        let options: FileOptions<'_, ()> =
409            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
410        writer.start_file("pack.manifest.json", options)?;
411        writer.write_all(
412            serde_json::json!({
413                "pack_id": pack_id,
414                "display_name": pack_id,
415            })
416            .to_string()
417            .as_bytes(),
418        )?;
419        writer.start_file("assets/secret-requirements.json", options)?;
420        writer.write_all(
421            serde_json::json!([{ "key": secret_key }])
422                .to_string()
423                .as_bytes(),
424        )?;
425        writer.finish()?;
426        Ok(())
427    }
428
429    #[test]
430    fn emit_answers_includes_app_pack_secret_questions() -> anyhow::Result<()> {
431        let temp = tempfile::tempdir()?;
432        let bundle_root = temp.path().join("bundle");
433        crate::bundle::create_demo_bundle_structure(&bundle_root, Some("weather-demo"))?;
434
435        let pack_path = bundle_root.join("packs").join("weather-app.gtpack");
436        write_app_pack(&pack_path, "weather-app", "WEATHER_API_KEY")?;
437
438        let engine = SetupEngine::new(SetupConfig {
439            tenant: "demo".to_string(),
440            team: None,
441            env: "dev".to_string(),
442            offline: false,
443            verbose: false,
444        });
445        let request = SetupRequest {
446            bundle: bundle_root.clone(),
447            tenants: vec![TenantSelection {
448                tenant: "demo".to_string(),
449                team: None,
450                allow_paths: Vec::new(),
451            }],
452            update_ops: BTreeSet::new(),
453            static_routes: StaticRoutesPolicy::default(),
454            ..Default::default()
455        };
456        let plan = engine.plan(crate::SetupMode::Create, &request, true)?;
457
458        let answers_path = temp.path().join("answers.json");
459        emit_answers(engine.config(), &plan, &answers_path, None, false)?;
460
461        let doc: serde_json::Value =
462            serde_json::from_str(&std::fs::read_to_string(&answers_path)?)?;
463        assert_eq!(
464            doc.pointer("/setup_answers/weather-app/weather_api_key"),
465            Some(&Value::String(String::new()))
466        );
467        Ok(())
468    }
469}