1pub mod bundle;
2pub mod context;
3pub mod template;
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::PathBuf;
6
7use crate::config::schema::Config;
8use crate::error::{Error, Result};
9use crate::exposure::Exposure;
10use crate::registry::service_def::{AuthKind, EnvKind, EnvVar, PortDef, ServiceDef};
11
12#[derive(Debug)]
13pub struct GeneratedFile {
14 pub path: PathBuf,
15 pub content: String,
16}
17
18pub struct GenerateEnvParams<'a> {
20 pub config: &'a Config,
21 pub service_def: &'a ServiceDef,
22 pub auth_kind: Option<&'a AuthKind>,
24 pub host_port: Option<u16>,
26 pub resolved_ports: &'a [(String, u16)],
30 pub env_overrides: &'a BTreeMap<String, String>,
31 pub exposure: &'a Exposure,
34 pub extra_env: BTreeMap<String, String>,
36 pub pre_built_ctx: Option<BTreeMap<String, String>>,
41 pub enable_smtp: bool,
46 pub enabled_groups: &'a BTreeSet<String>,
49 pub selected_choices: &'a BTreeMap<String, String>,
53 pub existing_env_file: Option<&'a str>,
61 pub allow_unset_required: bool,
68}
69
70pub struct EnvOutput {
72 pub env_file: GeneratedFile,
73 pub ctx: BTreeMap<String, String>,
75}
76
77pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
79 let name = ¶ms.service_def.service.name;
80
81 let mut ctx = context::build_context(
85 params.config,
86 params.service_def,
87 params.host_port,
88 params.auth_kind,
89 params.exposure,
90 params.enable_smtp,
91 )?;
92 if let Some(prebuilt) = params.pre_built_ctx {
93 for (key, value) in prebuilt {
94 if key.starts_with("secret.") || key.starts_with("auth.") {
95 ctx.insert(key, value);
96 }
97 }
98 }
99 let mut eff_ports: Vec<&PortDef> = params.service_def.ports.iter().collect();
102 for choice in ¶ms.service_def.choices {
103 let sel = params
104 .selected_choices
105 .get(&choice.name)
106 .unwrap_or(&choice.default);
107 if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
108 eff_ports.extend(opt.ports.iter());
109 }
110 }
111 insert_port_urls(
112 &mut ctx,
113 &eff_ports,
114 params.resolved_ports,
115 params.exposure.url(),
116 );
117
118 let rendered_env = render_env_vars(
119 params.service_def,
120 &ctx,
121 params.env_overrides,
122 params.auth_kind,
123 params.enabled_groups,
124 params.selected_choices,
125 params.allow_unset_required,
126 )?;
127
128 let home_dir = crate::service_home(name)?;
133 let generated = build_env_pairs(
134 &home_dir,
135 &rendered_env,
136 params.resolved_ports,
137 ¶ms.extra_env,
138 params.env_overrides,
139 );
140
141 let explicit: BTreeSet<&str> = params.env_overrides.keys().map(String::as_str).collect();
145 let content = merge_env_file(params.existing_env_file, &generated, &explicit);
146 let env_file = GeneratedFile {
147 path: home_dir.join(".env"),
148 content,
149 };
150
151 Ok(EnvOutput { env_file, ctx })
152}
153
154fn merge_env_file(
164 existing: Option<&str>,
165 generated: &[(String, String)],
166 explicit: &BTreeSet<&str>,
167) -> String {
168 let render_fresh = || {
169 generated
170 .iter()
171 .map(|(k, v)| format!("{k}={v}"))
172 .collect::<Vec<_>>()
173 .join("\n")
174 + "\n"
175 };
176 let Some(existing) = existing else {
177 return render_fresh();
178 };
179 let gen_map: BTreeMap<&str, &str> = generated
180 .iter()
181 .map(|(k, v)| (k.as_str(), v.as_str()))
182 .collect();
183 let mut out: Vec<String> = Vec::new();
184 let mut seen: BTreeSet<&str> = BTreeSet::new();
185 for line in existing.lines() {
186 let trimmed = line.trim_start();
187 if trimmed.is_empty() || trimmed.starts_with('#') {
188 out.push(line.to_string());
189 continue;
190 }
191 let Some((raw_key, _)) = line.split_once('=') else {
192 out.push(line.to_string());
194 continue;
195 };
196 let key = raw_key.trim();
197 seen.insert(key);
198 if explicit.contains(key)
201 && let Some(value) = gen_map.get(key)
202 {
203 out.push(format!("{key}={value}"));
204 continue;
205 }
206 out.push(line.to_string());
207 }
208 for (key, value) in generated {
210 if !seen.contains(key.as_str()) {
211 out.push(format!("{key}={value}"));
212 }
213 }
214 out.join("\n") + "\n"
215}
216
217fn insert_port_urls(
227 ctx: &mut BTreeMap<String, String>,
228 ports: &[&PortDef],
231 resolved_ports: &[(String, u16)],
232 url: Option<&str>,
233) {
234 for (name, port) in resolved_ports {
241 ctx.insert(format!("service.ports.{name}"), port.to_string());
242 }
243 let primary = ports
246 .iter()
247 .copied()
248 .find(|p| p.name.eq_ignore_ascii_case("http"))
249 .or_else(|| ports.first().copied())
250 .map(|p| p.name.clone());
251 let parsed = url.and_then(|u| url::Url::parse(u).ok());
252 let host = parsed
253 .as_ref()
254 .and_then(|u| u.host_str())
255 .map(str::to_string);
256 let scheme = parsed.as_ref().map(|u| u.scheme().to_string());
257 let is_ts = host.as_deref().is_some_and(|h| h.ends_with(".ts.net"));
258 let external_url = ctx.get("service.external_url").cloned();
259
260 for p in ports.iter().copied() {
261 let host_port = resolved_ports
262 .iter()
263 .find(|(n, _)| n == &p.name)
264 .map(|(_, hp)| *hp)
265 .or(p.host_port)
266 .unwrap_or(p.container_port);
267 let is_primary = primary.as_deref() == Some(p.name.as_str());
268 let port_url =
269 if let (true, Some(https), Some(h)) = (is_ts, p.tailscale_https, host.as_deref()) {
270 if https == 443 {
273 format!("https://{h}")
274 } else {
275 format!("https://{h}:{https}")
276 }
277 } else if is_primary && let Some(ext) = &external_url {
278 ext.clone()
279 } else if let (Some(s), Some(h)) = (scheme.as_deref(), host.as_deref()) {
280 format!("{s}://{h}:{host_port}")
283 } else {
284 format!("http://127.0.0.1:{host_port}")
285 };
286 ctx.insert(format!("service.port_url.{}", p.name), port_url);
287 }
288}
289
290fn build_env_pairs(
296 home_dir: &std::path::Path,
297 rendered_env: &[EnvVar],
298 resolved_ports: &[(String, u16)],
299 extra_env: &BTreeMap<String, String>,
300 env_overrides: &BTreeMap<String, String>,
301) -> Vec<(String, String)> {
302 let mut pairs: Vec<(String, String)> = Vec::new();
303
304 for env in rendered_env {
305 pairs.push((env.name.clone(), env.value.clone()));
310 }
311
312 pairs.push(("SERVICE_HOME".to_string(), home_dir.display().to_string()));
314
315 for (name, port) in resolved_ports {
320 pairs.push((
321 format!("SERVICE_PORT_{}", name.to_uppercase()),
322 port.to_string(),
323 ));
324 }
325
326 for (key, value) in extra_env {
328 pairs.push((key.clone(), value.clone()));
329 }
330
331 let emitted: BTreeSet<String> = pairs.iter().map(|(k, _)| k.clone()).collect();
336 for (key, value) in env_overrides {
337 if !emitted.contains(key.as_str()) {
338 pairs.push((key.clone(), value.clone()));
339 }
340 }
341
342 pairs
343}
344
345fn render_env_vars(
348 service_def: &ServiceDef,
349 ctx: &BTreeMap<String, String>,
350 env_overrides: &BTreeMap<String, String>,
351 auth_kind: Option<&AuthKind>,
352 enabled_groups: &BTreeSet<String>,
353 selected_choices: &BTreeMap<String, String>,
354 allow_unset_required: bool,
355) -> Result<Vec<EnvVar>> {
356 let mut rendered: Vec<EnvVar> = service_def
357 .env
358 .iter()
359 .map(|env| render_one(env, env_overrides, ctx, None, allow_unset_required))
360 .collect::<Result<Vec<_>>>()?;
361
362 for group in &service_def.env_groups {
365 if !enabled_groups.contains(&group.name) {
366 continue;
367 }
368 let loc = format!("group '{}'", group.name);
369 for env in &group.env {
370 rendered.push(render_one(
371 env,
372 env_overrides,
373 ctx,
374 Some(&loc),
375 allow_unset_required,
376 )?);
377 }
378 }
379
380 for choice in &service_def.choices {
385 let selected = selected_choices
386 .get(&choice.name)
387 .unwrap_or(&choice.default);
388 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
389 continue;
390 };
391 let loc = format!("choice '{}' option '{}'", choice.name, option.name);
392 for env in &option.env {
393 rendered.push(render_one(
394 env,
395 env_overrides,
396 ctx,
397 Some(&loc),
398 allow_unset_required,
399 )?);
400 }
401 }
402
403 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
404 for (env_name, value_template) in &service_def.mappings.smtp {
405 let value = template::render(value_template, ctx)?;
406 rendered.push(EnvVar {
409 name: env_name.clone(),
410 value,
411 kind: Default::default(),
412 prompt: None,
413 format: Default::default(),
414 length: None,
415 jwt_claims: None,
416 jwt_signing_key: None,
417 });
418 }
419 }
420 if auth_kind.is_some() {
421 for (env_name, value_template) in &service_def.mappings.auth {
422 let value = template::render(value_template, ctx)?;
423 if value.is_empty() {
424 return Err(Error::Template(format!(
425 "auth mapping {env_name} rendered to empty value from template: {value_template}"
426 )));
427 }
428 rendered.push(EnvVar {
429 name: env_name.clone(),
430 value,
431 kind: Default::default(),
432 prompt: None,
433 format: Default::default(),
434 length: None,
435 jwt_claims: None,
436 jwt_signing_key: None,
437 });
438 }
439 }
440
441 Ok(rendered)
442}
443
444fn render_one(
450 env: &EnvVar,
451 env_overrides: &BTreeMap<String, String>,
452 ctx: &BTreeMap<String, String>,
453 member_of: Option<&str>,
457 allow_unset_required: bool,
458) -> Result<EnvVar> {
459 let value = match env_overrides.get(&env.name) {
460 Some(override_value) => override_value.clone(),
461 None => {
462 if let Some(loc) = member_of
463 && env.kind == EnvKind::Required
464 && !allow_unset_required
465 {
466 return Err(Error::Template(format!(
467 "required env var '{}' in {loc} has no value; provide it via the interactive prompt or process env (or `--no-setup` to install and fill it in later)",
468 env.name
469 )));
470 }
471 template::render(&env.value, ctx)?
472 }
473 };
474 Ok(EnvVar {
475 name: env.name.clone(),
476 value,
477 kind: Default::default(),
478 prompt: None,
479 format: Default::default(),
480 length: None,
481 jwt_claims: None,
482 jwt_signing_key: None,
483 })
484}
485
486pub fn extract_secret_refs(value: &str) -> Vec<String> {
487 let mut secrets = Vec::new();
488 let mut rest = value;
489 while let Some(start) = rest.find("{{secret.") {
490 let after = &rest[start + 9..];
491 if let Some(end) = after.find("}}") {
492 secrets.push(after[..end].to_string());
493 rest = &after[end + 2..];
494 } else {
495 break;
496 }
497 }
498 secrets
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::config::schema::Config;
505 use crate::registry::service_def::{
506 EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
507 };
508
509 fn minimal_service_def() -> ServiceDef {
510 ServiceDef {
511 service: ServiceMeta {
512 name: "demo".into(),
513 description: "demo".into(),
514 url: None,
515 kind: Default::default(),
516 architecture: vec![],
517 https: Default::default(),
518 runtime: Default::default(),
519 run: None,
520 build: None,
521 post_install: None,
522 deploy: Default::default(),
523 health_check: None,
524 health_timeout: None,
525 },
526 requirements: None,
527 ports: vec![PortDef {
528 name: "http".into(),
529 container_port: 80,
530 host_port: None,
531 protocol: Default::default(),
532 tailscale_https: None,
533 }],
534 env: vec![
535 EnvVar {
536 name: "HOSTPORT".into(),
537 value: "{{service.port}}".into(),
538 kind: EnvKind::Default,
539 prompt: None,
540 format: Default::default(),
541 length: None,
542 jwt_claims: None,
543 jwt_signing_key: None,
544 },
545 EnvVar {
546 name: "ADMIN_PASSWORD".into(),
547 value: "{{secret.admin}}".into(),
548 kind: EnvKind::Default,
549 prompt: None,
550 format: Default::default(),
551 length: Some(16),
552 jwt_claims: None,
553 jwt_signing_key: None,
554 },
555 ],
556 env_groups: vec![],
557 choices: vec![],
558 requires: vec![],
559 mappings: Default::default(),
560 integrations: Default::default(),
561 capabilities: Default::default(),
562 backup: None,
563 metrics: None,
564 }
565 }
566
567 fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
568 EnvVar {
569 name: name.into(),
570 value: value.into(),
571 kind,
572 prompt: None,
573 format: Default::default(),
574 length: None,
575 jwt_claims: None,
576 jwt_signing_key: None,
577 }
578 }
579
580 fn def_with_oauth_group() -> ServiceDef {
581 let mut def = minimal_service_def();
582 def.env_groups.push(EnvGroup {
583 name: "google_oauth".into(),
584 prompt: "Enable Google?".into(),
585 env: vec![
586 plain_env("CLIENT_ID", "", EnvKind::Required),
587 plain_env("CLIENT_SECRET", "", EnvKind::Required),
588 plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
589 plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
590 ],
591 });
592 def
593 }
594
595 fn multiport_def() -> ServiceDef {
598 let mut def = minimal_service_def();
599 def.ports = vec![
600 PortDef {
601 name: "http".into(),
602 container_port: 8080,
603 host_port: None,
604 protocol: Default::default(),
605 tailscale_https: Some(8080),
606 },
607 PortDef {
608 name: "photos".into(),
609 container_port: 3000,
610 host_port: None,
611 protocol: Default::default(),
612 tailscale_https: Some(443),
613 },
614 ];
615 def
616 }
617
618 fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
619 let def = multiport_def();
620 let resolved = vec![
621 ("http".to_string(), 8080u16),
622 ("photos".to_string(), 10002u16),
623 ];
624 let mut ctx = BTreeMap::new();
625 ctx.insert("service.external_url".to_string(), external_url.to_string());
626 let ports: Vec<&PortDef> = def.ports.iter().collect();
627 insert_port_urls(&mut ctx, &ports, &resolved, url);
628 ctx
629 }
630
631 #[test]
632 fn merge_fresh_install_writes_generated_verbatim() {
633 let generated = vec![
634 ("A".to_string(), "1".to_string()),
635 ("B".to_string(), "2".to_string()),
636 ];
637 assert_eq!(
639 merge_env_file(None, &generated, &BTreeSet::new()),
640 "A=1\nB=2\n"
641 );
642 }
643
644 #[test]
645 fn merge_preserves_operator_keys_comments_and_untouched_values() {
646 let existing = "# operator notes\nRYRA_TOKEN=secret-abc\nSITE_TITLE=Custom\n";
649 let generated = vec![
650 ("SITE_TITLE".to_string(), "Default".to_string()),
651 ("ADMIN_EMAIL".to_string(), String::new()),
652 ("SERVICE_HOME".to_string(), "/home/x".to_string()),
653 ];
654 let merged = merge_env_file(Some(existing), &generated, &BTreeSet::new());
655 assert!(merged.contains("# operator notes"));
657 assert!(merged.contains("RYRA_TOKEN=secret-abc"));
658 assert!(merged.contains("SITE_TITLE=Custom"));
660 assert!(!merged.contains("SITE_TITLE=Default"));
661 assert!(merged.contains("ADMIN_EMAIL="));
663 assert!(merged.contains("SERVICE_HOME=/home/x"));
664 }
665
666 #[test]
667 fn merge_updates_only_explicitly_set_keys() {
668 let existing = "SITE_TITLE=Old\nKEEP=stays\n";
669 let generated = vec![
670 ("SITE_TITLE".to_string(), "New".to_string()),
671 ("KEEP".to_string(), "regenerated".to_string()),
672 ];
673 let explicit = BTreeSet::from(["SITE_TITLE"]);
674 let merged = merge_env_file(Some(existing), &generated, &explicit);
675 assert!(merged.contains("SITE_TITLE=New")); assert!(merged.contains("KEEP=stays")); assert!(!merged.contains("KEEP=regenerated"));
678 }
679
680 #[test]
681 fn port_url_loopback_uses_host_ports() {
682 let ctx = port_urls(None, "http://127.0.0.1:8080");
684 assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
685 assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
686 }
687
688 #[test]
689 fn port_url_raw_ip_url_exposes_each_port() {
690 let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
692 assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
693 assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
694 }
695
696 #[test]
697 fn port_url_tailscale_splits_root_and_api() {
698 let url = "https://ente-debian.cobbler-tuna.ts.net";
700 let ctx = port_urls(Some(url), url);
701 assert_eq!(
702 ctx["service.port_url.http"],
703 "https://ente-debian.cobbler-tuna.ts.net:8080"
704 );
705 assert_eq!(
706 ctx["service.port_url.photos"],
707 "https://ente-debian.cobbler-tuna.ts.net"
708 );
709 }
710
711 fn gen_with_group(
712 def: &ServiceDef,
713 enabled_groups: &BTreeSet<String>,
714 overrides: &BTreeMap<String, String>,
715 ) -> Result<String> {
716 let config = Config::default();
717 let resolved = vec![("http".to_string(), 10002u16)];
718 let output = generate_env(GenerateEnvParams {
719 config: &config,
720 service_def: def,
721 auth_kind: None,
722 host_port: Some(10002),
723 resolved_ports: &resolved,
724 env_overrides: overrides,
725 exposure: &Exposure::Loopback,
726 extra_env: BTreeMap::new(),
727 pre_built_ctx: None,
728 enable_smtp: false,
729 enabled_groups,
730 selected_choices: &BTreeMap::new(),
731 existing_env_file: None,
732 allow_unset_required: false,
733 })?;
734 Ok(output.env_file.content)
735 }
736
737 fn gen_with_choices(
738 def: &ServiceDef,
739 selected: &BTreeMap<String, String>,
740 overrides: &BTreeMap<String, String>,
741 ) -> Result<String> {
742 let config = Config::default();
743 let resolved = vec![("http".to_string(), 10002u16)];
744 let output = generate_env(GenerateEnvParams {
745 config: &config,
746 service_def: def,
747 auth_kind: None,
748 host_port: Some(10002),
749 resolved_ports: &resolved,
750 env_overrides: overrides,
751 exposure: &Exposure::Loopback,
752 extra_env: BTreeMap::new(),
753 pre_built_ctx: None,
754 enable_smtp: false,
755 enabled_groups: &BTreeSet::new(),
756 selected_choices: selected,
757 existing_env_file: None,
758 allow_unset_required: false,
759 })?;
760 Ok(output.env_file.content)
761 }
762
763 fn def_with_billing_choice() -> ServiceDef {
764 toml::from_str(
765 r#"
766[service]
767name = "billed"
768description = "x"
769
770[[ports]]
771name = "http"
772container_port = 8080
773
774[[choice]]
775name = "billing"
776prompt = "Billing mode"
777default = "mock"
778
779[[choice.option]]
780name = "live"
781[[choice.option.env]]
782name = "BILLING_MODE"
783value = "live"
784[[choice.option.env]]
785name = "STRIPE_SECRET_KEY"
786value = ""
787kind = "required"
788
789[[choice.option]]
790name = "mock"
791[[choice.option.env]]
792name = "BILLING_MODE"
793value = "mock"
794"#,
795 )
796 .expect("parse")
797 }
798
799 #[test]
800 fn choice_writes_only_selected_option_members() {
801 let def = def_with_billing_choice();
802 let mut selected = BTreeMap::new();
803 selected.insert("billing".to_string(), "mock".to_string());
804 let content =
805 gen_with_choices(&def, &selected, &BTreeMap::new()).expect("mock selection renders");
806 assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
807 assert!(!content.contains("STRIPE_SECRET_KEY"), "got: {content}");
809 }
810
811 #[test]
812 fn choice_option_secret_is_generated() {
813 let def = toml::from_str::<ServiceDef>(
817 r#"
818[service]
819name = "s"
820description = "x"
821[[ports]]
822name = "http"
823container_port = 8080
824[[choice]]
825name = "database"
826prompt = "Database"
827default = "internal"
828[[choice.option]]
829name = "internal"
830[[choice.option.env]]
831name = "DB_PASSWORD"
832value = "{{secret.db_password}}"
833[[choice.option]]
834name = "external"
835[[choice.option.env]]
836name = "DB_PASSWORD"
837value = ""
838kind = "required"
839"#,
840 )
841 .expect("parse");
842 let mut selected = BTreeMap::new();
843 selected.insert("database".to_string(), "internal".to_string());
844 let content = gen_with_choices(&def, &selected, &BTreeMap::new())
845 .expect("renders with generated secret");
846 let line = content
847 .lines()
848 .find(|l| l.starts_with("DB_PASSWORD="))
849 .expect("DB_PASSWORD present");
850 let val = line.trim_start_matches("DB_PASSWORD=");
851 assert!(!val.is_empty() && !val.contains("{{"), "got: {line}");
852 }
853
854 #[test]
855 fn choice_falls_back_to_default_when_unselected() {
856 let def = def_with_billing_choice();
857 let content = gen_with_choices(&def, &BTreeMap::new(), &BTreeMap::new())
859 .expect("default selection renders");
860 assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
861 }
862
863 #[test]
864 fn choice_required_member_needs_a_value() {
865 let def = def_with_billing_choice();
868 let mut selected = BTreeMap::new();
869 selected.insert("billing".to_string(), "live".to_string());
870 let err = gen_with_choices(&def, &selected, &BTreeMap::new())
871 .expect_err("required member without value must fail");
872 assert!(
873 format!("{err}").contains("STRIPE_SECRET_KEY"),
874 "error names the missing var: {err}"
875 );
876 }
877
878 #[test]
879 fn choice_required_member_value_is_written() {
880 let def = def_with_billing_choice();
881 let mut selected = BTreeMap::new();
882 selected.insert("billing".to_string(), "live".to_string());
883 let mut overrides = BTreeMap::new();
884 overrides.insert("STRIPE_SECRET_KEY".to_string(), "sk_test_123".to_string());
885 let content = gen_with_choices(&def, &selected, &overrides).expect("live renders");
886 assert!(content.contains("BILLING_MODE=live"), "got: {content}");
887 assert!(
888 content.contains("STRIPE_SECRET_KEY=sk_test_123"),
889 "got: {content}"
890 );
891 }
892
893 #[test]
894 fn env_group_disabled_writes_no_members() {
895 let def = def_with_oauth_group();
896 let no_groups = BTreeSet::new();
897 let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
898 .expect("generate_env should succeed with no groups enabled");
899 for name in [
900 "CLIENT_ID",
901 "CLIENT_SECRET",
902 "CALLBACK_URL",
903 "OAUTH_ENABLED",
904 ] {
905 assert!(
906 !content.contains(&format!("{name}=")),
907 "disabled group member '{name}' leaked into .env: {content}"
908 );
909 }
910 }
911
912 #[test]
913 fn env_group_enabled_writes_all_members() {
914 let def = def_with_oauth_group();
915 let mut enabled = BTreeSet::new();
916 enabled.insert("google_oauth".to_string());
917 let mut overrides = BTreeMap::new();
918 overrides.insert("CLIENT_ID".into(), "my-client".into());
919 overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
920 let content = gen_with_group(&def, &enabled, &overrides)
921 .expect("generate_env should succeed with the group enabled + overrides supplied");
922 assert!(content.contains("CLIENT_ID=my-client"), "{content}");
923 assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
924 assert!(
925 content.contains("CALLBACK_URL=https://demo/cb"),
926 "{content}"
927 );
928 assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
929 }
930
931 #[test]
932 fn env_group_enabled_required_member_without_override_errors() {
933 let def = def_with_oauth_group();
934 let mut enabled = BTreeSet::new();
935 enabled.insert("google_oauth".to_string());
936 let mut overrides = BTreeMap::new();
939 overrides.insert("CLIENT_ID".into(), "my-client".into());
940 let err = gen_with_group(&def, &enabled, &overrides)
941 .expect_err("required member missing must surface as an error");
942 let msg = err.to_string();
943 assert!(
944 msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
945 "error should name the missing member + group: {msg}"
946 );
947 }
948
949 #[test]
956 fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
957 let def = minimal_service_def();
958 let config = Config::default();
959 let prebuilt =
962 context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
963 .expect("build_context with host_port=None should succeed");
964 assert!(!prebuilt.contains_key("service.port"));
965 let admin_secret = prebuilt
966 .get("secret.admin")
967 .expect("secret.admin should have been generated in the prompt phase")
968 .clone();
969
970 let resolved = vec![("http".to_string(), 10002u16)];
972 let no_groups = BTreeSet::new();
973 let output = generate_env(GenerateEnvParams {
974 config: &config,
975 service_def: &def,
976 auth_kind: None,
977 host_port: Some(10002),
978 resolved_ports: &resolved,
979 env_overrides: &BTreeMap::new(),
980 exposure: &Exposure::Loopback,
981 extra_env: BTreeMap::new(),
982 pre_built_ctx: Some(prebuilt),
983 enable_smtp: false,
984 enabled_groups: &no_groups,
985 selected_choices: &BTreeMap::new(),
986 existing_env_file: None,
987 allow_unset_required: false,
988 })
989 .expect("generate_env must succeed with the real host_port");
990
991 assert!(
994 output.env_file.content.contains("HOSTPORT=10002"),
995 ".env missing real port: {}",
996 output.env_file.content,
997 );
998 assert!(
999 output
1000 .env_file
1001 .content
1002 .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
1003 "prompt-phase secret not preserved in .env: {}",
1004 output.env_file.content,
1005 );
1006 }
1007}