1use 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
19pub 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 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 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 for (provider_id, answers) in &plan.metadata.setup_answers {
71 setup_answers.insert(provider_id.clone(), answers.clone());
72 }
73
74 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 if interactive {
107 prompt_secret_answers(bundle, &mut answers_doc)?;
108 }
109
110 encrypt_secret_answers(bundle, &mut answers_doc, key, interactive)?;
111
112 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
128pub 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
209pub 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 let mut secret_questions: Vec<(String, String, String, bool)> = Vec::new(); 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 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 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
303pub 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}