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}
54
55pub struct EnvOutput {
57 pub env_file: GeneratedFile,
58 pub ctx: BTreeMap<String, String>,
60}
61
62pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
64 let name = ¶ms.service_def.service.name;
65
66 let mut ctx = context::build_context(
70 params.config,
71 params.service_def,
72 params.host_port,
73 params.auth_kind,
74 params.exposure,
75 params.enable_smtp,
76 )?;
77 if let Some(prebuilt) = params.pre_built_ctx {
78 for (key, value) in prebuilt {
79 if key.starts_with("secret.") || key.starts_with("auth.") {
80 ctx.insert(key, value);
81 }
82 }
83 }
84 let mut eff_ports: Vec<&PortDef> = params.service_def.ports.iter().collect();
87 for choice in ¶ms.service_def.choices {
88 let sel = params
89 .selected_choices
90 .get(&choice.name)
91 .unwrap_or(&choice.default);
92 if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
93 eff_ports.extend(opt.ports.iter());
94 }
95 }
96 insert_port_urls(
97 &mut ctx,
98 &eff_ports,
99 params.resolved_ports,
100 params.exposure.url(),
101 );
102
103 let rendered_env = render_env_vars(
104 params.service_def,
105 &ctx,
106 params.env_overrides,
107 params.auth_kind,
108 params.enabled_groups,
109 params.selected_choices,
110 )?;
111
112 let home_dir = crate::service_home(name)?;
114 let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
115
116 for (key, value) in ¶ms.extra_env {
118 env_file.content.push_str(&format!("{key}={value}\n"));
119 }
120
121 Ok(EnvOutput { env_file, ctx })
122}
123
124fn insert_port_urls(
134 ctx: &mut BTreeMap<String, String>,
135 ports: &[&PortDef],
138 resolved_ports: &[(String, u16)],
139 url: Option<&str>,
140) {
141 for (name, port) in resolved_ports {
148 ctx.insert(format!("service.ports.{name}"), port.to_string());
149 }
150 let primary = ports
153 .iter()
154 .copied()
155 .find(|p| p.name.eq_ignore_ascii_case("http"))
156 .or_else(|| ports.first().copied())
157 .map(|p| p.name.clone());
158 let parsed = url.and_then(|u| url::Url::parse(u).ok());
159 let host = parsed
160 .as_ref()
161 .and_then(|u| u.host_str())
162 .map(str::to_string);
163 let scheme = parsed.as_ref().map(|u| u.scheme().to_string());
164 let is_ts = host.as_deref().is_some_and(|h| h.ends_with(".ts.net"));
165 let external_url = ctx.get("service.external_url").cloned();
166
167 for p in ports.iter().copied() {
168 let host_port = resolved_ports
169 .iter()
170 .find(|(n, _)| n == &p.name)
171 .map(|(_, hp)| *hp)
172 .or(p.host_port)
173 .unwrap_or(p.container_port);
174 let is_primary = primary.as_deref() == Some(p.name.as_str());
175 let port_url =
176 if let (true, Some(https), Some(h)) = (is_ts, p.tailscale_https, host.as_deref()) {
177 if https == 443 {
180 format!("https://{h}")
181 } else {
182 format!("https://{h}:{https}")
183 }
184 } else if is_primary && let Some(ext) = &external_url {
185 ext.clone()
186 } else if let (Some(s), Some(h)) = (scheme.as_deref(), host.as_deref()) {
187 format!("{s}://{h}:{host_port}")
190 } else {
191 format!("http://127.0.0.1:{host_port}")
192 };
193 ctx.insert(format!("service.port_url.{}", p.name), port_url);
194 }
195}
196
197fn build_env_file(
199 home_dir: &std::path::Path,
200 rendered_env: &[EnvVar],
201 resolved_ports: &[(String, u16)],
202) -> GeneratedFile {
203 let mut lines = Vec::new();
204
205 for env in rendered_env {
206 lines.push(format!("{}={}", env.name, env.value));
211 }
212
213 lines.push(format!("SERVICE_HOME={}", home_dir.display()));
215
216 for (name, port) in resolved_ports {
221 let var_name = format!("SERVICE_PORT_{}", name.to_uppercase());
222 lines.push(format!("{var_name}={port}"));
223 }
224
225 GeneratedFile {
226 path: home_dir.join(".env"),
227 content: lines.join("\n") + "\n",
228 }
229}
230
231fn render_env_vars(
234 service_def: &ServiceDef,
235 ctx: &BTreeMap<String, String>,
236 env_overrides: &BTreeMap<String, String>,
237 auth_kind: Option<&AuthKind>,
238 enabled_groups: &BTreeSet<String>,
239 selected_choices: &BTreeMap<String, String>,
240) -> Result<Vec<EnvVar>> {
241 let mut rendered: Vec<EnvVar> = service_def
242 .env
243 .iter()
244 .map(|env| render_one(env, env_overrides, ctx, None))
245 .collect::<Result<Vec<_>>>()?;
246
247 for group in &service_def.env_groups {
250 if !enabled_groups.contains(&group.name) {
251 continue;
252 }
253 let loc = format!("group '{}'", group.name);
254 for env in &group.env {
255 rendered.push(render_one(env, env_overrides, ctx, Some(&loc))?);
256 }
257 }
258
259 for choice in &service_def.choices {
264 let selected = selected_choices
265 .get(&choice.name)
266 .unwrap_or(&choice.default);
267 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
268 continue;
269 };
270 let loc = format!("choice '{}' option '{}'", choice.name, option.name);
271 for env in &option.env {
272 rendered.push(render_one(env, env_overrides, ctx, Some(&loc))?);
273 }
274 }
275
276 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
277 for (env_name, value_template) in &service_def.mappings.smtp {
278 let value = template::render(value_template, ctx)?;
279 rendered.push(EnvVar {
282 name: env_name.clone(),
283 value,
284 kind: Default::default(),
285 prompt: None,
286 format: Default::default(),
287 length: None,
288 jwt_claims: None,
289 jwt_signing_key: None,
290 });
291 }
292 }
293 if auth_kind.is_some() {
294 for (env_name, value_template) in &service_def.mappings.auth {
295 let value = template::render(value_template, ctx)?;
296 if value.is_empty() {
297 return Err(Error::Template(format!(
298 "auth mapping {env_name} rendered to empty value from template: {value_template}"
299 )));
300 }
301 rendered.push(EnvVar {
302 name: env_name.clone(),
303 value,
304 kind: Default::default(),
305 prompt: None,
306 format: Default::default(),
307 length: None,
308 jwt_claims: None,
309 jwt_signing_key: None,
310 });
311 }
312 }
313
314 Ok(rendered)
315}
316
317fn render_one(
321 env: &EnvVar,
322 env_overrides: &BTreeMap<String, String>,
323 ctx: &BTreeMap<String, String>,
324 member_of: Option<&str>,
328) -> Result<EnvVar> {
329 let value = match env_overrides.get(&env.name) {
330 Some(override_value) => override_value.clone(),
331 None => {
332 if let Some(loc) = member_of
333 && env.kind == EnvKind::Required
334 {
335 return Err(Error::Template(format!(
336 "required env var '{}' in {loc} has no value; provide it via the interactive prompt or process env",
337 env.name
338 )));
339 }
340 template::render(&env.value, ctx)?
341 }
342 };
343 Ok(EnvVar {
344 name: env.name.clone(),
345 value,
346 kind: Default::default(),
347 prompt: None,
348 format: Default::default(),
349 length: None,
350 jwt_claims: None,
351 jwt_signing_key: None,
352 })
353}
354
355pub fn extract_secret_refs(value: &str) -> Vec<String> {
356 let mut secrets = Vec::new();
357 let mut rest = value;
358 while let Some(start) = rest.find("{{secret.") {
359 let after = &rest[start + 9..];
360 if let Some(end) = after.find("}}") {
361 secrets.push(after[..end].to_string());
362 rest = &after[end + 2..];
363 } else {
364 break;
365 }
366 }
367 secrets
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::config::schema::Config;
374 use crate::registry::service_def::{
375 EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
376 };
377
378 fn minimal_service_def() -> ServiceDef {
379 ServiceDef {
380 service: ServiceMeta {
381 name: "demo".into(),
382 description: "demo".into(),
383 url: None,
384 kind: Default::default(),
385 architecture: vec![],
386 https: Default::default(),
387 runtime: Default::default(),
388 run: None,
389 build: None,
390 post_install: None,
391 deploy: Default::default(),
392 health_check: None,
393 health_timeout: None,
394 },
395 requirements: None,
396 ports: vec![PortDef {
397 name: "http".into(),
398 container_port: 80,
399 host_port: None,
400 protocol: Default::default(),
401 tailscale_https: None,
402 }],
403 env: vec![
404 EnvVar {
405 name: "HOSTPORT".into(),
406 value: "{{service.port}}".into(),
407 kind: EnvKind::Default,
408 prompt: None,
409 format: Default::default(),
410 length: None,
411 jwt_claims: None,
412 jwt_signing_key: None,
413 },
414 EnvVar {
415 name: "ADMIN_PASSWORD".into(),
416 value: "{{secret.admin}}".into(),
417 kind: EnvKind::Default,
418 prompt: None,
419 format: Default::default(),
420 length: Some(16),
421 jwt_claims: None,
422 jwt_signing_key: None,
423 },
424 ],
425 env_groups: vec![],
426 choices: vec![],
427 requires: vec![],
428 mappings: Default::default(),
429 integrations: Default::default(),
430 capabilities: Default::default(),
431 backup: None,
432 metrics: None,
433 }
434 }
435
436 fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
437 EnvVar {
438 name: name.into(),
439 value: value.into(),
440 kind,
441 prompt: None,
442 format: Default::default(),
443 length: None,
444 jwt_claims: None,
445 jwt_signing_key: None,
446 }
447 }
448
449 fn def_with_oauth_group() -> ServiceDef {
450 let mut def = minimal_service_def();
451 def.env_groups.push(EnvGroup {
452 name: "google_oauth".into(),
453 prompt: "Enable Google?".into(),
454 env: vec![
455 plain_env("CLIENT_ID", "", EnvKind::Required),
456 plain_env("CLIENT_SECRET", "", EnvKind::Required),
457 plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
458 plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
459 ],
460 });
461 def
462 }
463
464 fn multiport_def() -> ServiceDef {
467 let mut def = minimal_service_def();
468 def.ports = vec![
469 PortDef {
470 name: "http".into(),
471 container_port: 8080,
472 host_port: None,
473 protocol: Default::default(),
474 tailscale_https: Some(8080),
475 },
476 PortDef {
477 name: "photos".into(),
478 container_port: 3000,
479 host_port: None,
480 protocol: Default::default(),
481 tailscale_https: Some(443),
482 },
483 ];
484 def
485 }
486
487 fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
488 let def = multiport_def();
489 let resolved = vec![
490 ("http".to_string(), 8080u16),
491 ("photos".to_string(), 10002u16),
492 ];
493 let mut ctx = BTreeMap::new();
494 ctx.insert("service.external_url".to_string(), external_url.to_string());
495 let ports: Vec<&PortDef> = def.ports.iter().collect();
496 insert_port_urls(&mut ctx, &ports, &resolved, url);
497 ctx
498 }
499
500 #[test]
501 fn port_url_loopback_uses_host_ports() {
502 let ctx = port_urls(None, "http://127.0.0.1:8080");
504 assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
505 assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
506 }
507
508 #[test]
509 fn port_url_raw_ip_url_exposes_each_port() {
510 let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
512 assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
513 assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
514 }
515
516 #[test]
517 fn port_url_tailscale_splits_root_and_api() {
518 let url = "https://ente-debian.cobbler-tuna.ts.net";
520 let ctx = port_urls(Some(url), url);
521 assert_eq!(
522 ctx["service.port_url.http"],
523 "https://ente-debian.cobbler-tuna.ts.net:8080"
524 );
525 assert_eq!(
526 ctx["service.port_url.photos"],
527 "https://ente-debian.cobbler-tuna.ts.net"
528 );
529 }
530
531 fn gen_with_group(
532 def: &ServiceDef,
533 enabled_groups: &BTreeSet<String>,
534 overrides: &BTreeMap<String, String>,
535 ) -> Result<String> {
536 let config = Config::default();
537 let resolved = vec![("http".to_string(), 10002u16)];
538 let output = generate_env(GenerateEnvParams {
539 config: &config,
540 service_def: def,
541 auth_kind: None,
542 host_port: Some(10002),
543 resolved_ports: &resolved,
544 env_overrides: overrides,
545 exposure: &Exposure::Loopback,
546 extra_env: BTreeMap::new(),
547 pre_built_ctx: None,
548 enable_smtp: false,
549 enabled_groups,
550 selected_choices: &BTreeMap::new(),
551 })?;
552 Ok(output.env_file.content)
553 }
554
555 fn gen_with_choices(
556 def: &ServiceDef,
557 selected: &BTreeMap<String, String>,
558 overrides: &BTreeMap<String, String>,
559 ) -> Result<String> {
560 let config = Config::default();
561 let resolved = vec![("http".to_string(), 10002u16)];
562 let output = generate_env(GenerateEnvParams {
563 config: &config,
564 service_def: def,
565 auth_kind: None,
566 host_port: Some(10002),
567 resolved_ports: &resolved,
568 env_overrides: overrides,
569 exposure: &Exposure::Loopback,
570 extra_env: BTreeMap::new(),
571 pre_built_ctx: None,
572 enable_smtp: false,
573 enabled_groups: &BTreeSet::new(),
574 selected_choices: selected,
575 })?;
576 Ok(output.env_file.content)
577 }
578
579 fn def_with_billing_choice() -> ServiceDef {
580 toml::from_str(
581 r#"
582[service]
583name = "billed"
584description = "x"
585
586[[ports]]
587name = "http"
588container_port = 8080
589
590[[choice]]
591name = "billing"
592prompt = "Billing mode"
593default = "mock"
594
595[[choice.option]]
596name = "live"
597[[choice.option.env]]
598name = "BILLING_MODE"
599value = "live"
600[[choice.option.env]]
601name = "STRIPE_SECRET_KEY"
602value = ""
603kind = "required"
604
605[[choice.option]]
606name = "mock"
607[[choice.option.env]]
608name = "BILLING_MODE"
609value = "mock"
610"#,
611 )
612 .expect("parse")
613 }
614
615 #[test]
616 fn choice_writes_only_selected_option_members() {
617 let def = def_with_billing_choice();
618 let mut selected = BTreeMap::new();
619 selected.insert("billing".to_string(), "mock".to_string());
620 let content =
621 gen_with_choices(&def, &selected, &BTreeMap::new()).expect("mock selection renders");
622 assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
623 assert!(!content.contains("STRIPE_SECRET_KEY"), "got: {content}");
625 }
626
627 #[test]
628 fn choice_option_secret_is_generated() {
629 let def = toml::from_str::<ServiceDef>(
633 r#"
634[service]
635name = "s"
636description = "x"
637[[ports]]
638name = "http"
639container_port = 8080
640[[choice]]
641name = "database"
642prompt = "Database"
643default = "internal"
644[[choice.option]]
645name = "internal"
646[[choice.option.env]]
647name = "DB_PASSWORD"
648value = "{{secret.db_password}}"
649[[choice.option]]
650name = "external"
651[[choice.option.env]]
652name = "DB_PASSWORD"
653value = ""
654kind = "required"
655"#,
656 )
657 .expect("parse");
658 let mut selected = BTreeMap::new();
659 selected.insert("database".to_string(), "internal".to_string());
660 let content = gen_with_choices(&def, &selected, &BTreeMap::new())
661 .expect("renders with generated secret");
662 let line = content
663 .lines()
664 .find(|l| l.starts_with("DB_PASSWORD="))
665 .expect("DB_PASSWORD present");
666 let val = line.trim_start_matches("DB_PASSWORD=");
667 assert!(!val.is_empty() && !val.contains("{{"), "got: {line}");
668 }
669
670 #[test]
671 fn choice_falls_back_to_default_when_unselected() {
672 let def = def_with_billing_choice();
673 let content = gen_with_choices(&def, &BTreeMap::new(), &BTreeMap::new())
675 .expect("default selection renders");
676 assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
677 }
678
679 #[test]
680 fn choice_required_member_needs_a_value() {
681 let def = def_with_billing_choice();
684 let mut selected = BTreeMap::new();
685 selected.insert("billing".to_string(), "live".to_string());
686 let err = gen_with_choices(&def, &selected, &BTreeMap::new())
687 .expect_err("required member without value must fail");
688 assert!(
689 format!("{err}").contains("STRIPE_SECRET_KEY"),
690 "error names the missing var: {err}"
691 );
692 }
693
694 #[test]
695 fn choice_required_member_value_is_written() {
696 let def = def_with_billing_choice();
697 let mut selected = BTreeMap::new();
698 selected.insert("billing".to_string(), "live".to_string());
699 let mut overrides = BTreeMap::new();
700 overrides.insert("STRIPE_SECRET_KEY".to_string(), "sk_test_123".to_string());
701 let content = gen_with_choices(&def, &selected, &overrides).expect("live renders");
702 assert!(content.contains("BILLING_MODE=live"), "got: {content}");
703 assert!(
704 content.contains("STRIPE_SECRET_KEY=sk_test_123"),
705 "got: {content}"
706 );
707 }
708
709 #[test]
710 fn env_group_disabled_writes_no_members() {
711 let def = def_with_oauth_group();
712 let no_groups = BTreeSet::new();
713 let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
714 .expect("generate_env should succeed with no groups enabled");
715 for name in [
716 "CLIENT_ID",
717 "CLIENT_SECRET",
718 "CALLBACK_URL",
719 "OAUTH_ENABLED",
720 ] {
721 assert!(
722 !content.contains(&format!("{name}=")),
723 "disabled group member '{name}' leaked into .env: {content}"
724 );
725 }
726 }
727
728 #[test]
729 fn env_group_enabled_writes_all_members() {
730 let def = def_with_oauth_group();
731 let mut enabled = BTreeSet::new();
732 enabled.insert("google_oauth".to_string());
733 let mut overrides = BTreeMap::new();
734 overrides.insert("CLIENT_ID".into(), "my-client".into());
735 overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
736 let content = gen_with_group(&def, &enabled, &overrides)
737 .expect("generate_env should succeed with the group enabled + overrides supplied");
738 assert!(content.contains("CLIENT_ID=my-client"), "{content}");
739 assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
740 assert!(
741 content.contains("CALLBACK_URL=https://demo/cb"),
742 "{content}"
743 );
744 assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
745 }
746
747 #[test]
748 fn env_group_enabled_required_member_without_override_errors() {
749 let def = def_with_oauth_group();
750 let mut enabled = BTreeSet::new();
751 enabled.insert("google_oauth".to_string());
752 let mut overrides = BTreeMap::new();
755 overrides.insert("CLIENT_ID".into(), "my-client".into());
756 let err = gen_with_group(&def, &enabled, &overrides)
757 .expect_err("required member missing must surface as an error");
758 let msg = err.to_string();
759 assert!(
760 msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
761 "error should name the missing member + group: {msg}"
762 );
763 }
764
765 #[test]
772 fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
773 let def = minimal_service_def();
774 let config = Config::default();
775 let prebuilt =
778 context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
779 .expect("build_context with host_port=None should succeed");
780 assert!(!prebuilt.contains_key("service.port"));
781 let admin_secret = prebuilt
782 .get("secret.admin")
783 .expect("secret.admin should have been generated in the prompt phase")
784 .clone();
785
786 let resolved = vec![("http".to_string(), 10002u16)];
788 let no_groups = BTreeSet::new();
789 let output = generate_env(GenerateEnvParams {
790 config: &config,
791 service_def: &def,
792 auth_kind: None,
793 host_port: Some(10002),
794 resolved_ports: &resolved,
795 env_overrides: &BTreeMap::new(),
796 exposure: &Exposure::Loopback,
797 extra_env: BTreeMap::new(),
798 pre_built_ctx: Some(prebuilt),
799 enable_smtp: false,
800 enabled_groups: &no_groups,
801 selected_choices: &BTreeMap::new(),
802 })
803 .expect("generate_env must succeed with the real host_port");
804
805 assert!(
808 output.env_file.content.contains("HOSTPORT=10002"),
809 ".env missing real port: {}",
810 output.env_file.content,
811 );
812 assert!(
813 output
814 .env_file
815 .content
816 .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
817 "prompt-phase secret not preserved in .env: {}",
818 output.env_file.content,
819 );
820 }
821}