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, 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}
50
51pub struct EnvOutput {
53 pub env_file: GeneratedFile,
54 pub ctx: BTreeMap<String, String>,
56}
57
58pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
60 let name = ¶ms.service_def.service.name;
61
62 let mut ctx = context::build_context(
66 params.config,
67 params.service_def,
68 params.host_port,
69 params.auth_kind,
70 params.exposure,
71 params.enable_smtp,
72 )?;
73 if let Some(prebuilt) = params.pre_built_ctx {
74 for (key, value) in prebuilt {
75 if key.starts_with("secret.") || key.starts_with("auth.") {
76 ctx.insert(key, value);
77 }
78 }
79 }
80 insert_port_urls(
81 &mut ctx,
82 params.service_def,
83 params.resolved_ports,
84 params.exposure.url(),
85 );
86
87 let rendered_env = render_env_vars(
88 params.service_def,
89 &ctx,
90 params.env_overrides,
91 params.auth_kind,
92 params.enabled_groups,
93 )?;
94
95 let home_dir = crate::service_home(name)?;
97 let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
98
99 for (key, value) in ¶ms.extra_env {
101 env_file.content.push_str(&format!("{key}={value}\n"));
102 }
103
104 Ok(EnvOutput { env_file, ctx })
105}
106
107fn insert_port_urls(
117 ctx: &mut BTreeMap<String, String>,
118 service_def: &ServiceDef,
119 resolved_ports: &[(String, u16)],
120 url: Option<&str>,
121) {
122 let primary = service_def
125 .ports
126 .iter()
127 .find(|p| p.name.eq_ignore_ascii_case("http"))
128 .or_else(|| service_def.ports.first())
129 .map(|p| p.name.clone());
130 let parsed = url.and_then(|u| url::Url::parse(u).ok());
131 let host = parsed
132 .as_ref()
133 .and_then(|u| u.host_str())
134 .map(str::to_string);
135 let scheme = parsed.as_ref().map(|u| u.scheme().to_string());
136 let is_ts = host.as_deref().is_some_and(|h| h.ends_with(".ts.net"));
137 let external_url = ctx.get("service.external_url").cloned();
138
139 for p in &service_def.ports {
140 let host_port = resolved_ports
141 .iter()
142 .find(|(n, _)| n == &p.name)
143 .map(|(_, hp)| *hp)
144 .or(p.host_port)
145 .unwrap_or(p.container_port);
146 let is_primary = primary.as_deref() == Some(p.name.as_str());
147 let port_url =
148 if let (true, Some(https), Some(h)) = (is_ts, p.tailscale_https, host.as_deref()) {
149 if https == 443 {
152 format!("https://{h}")
153 } else {
154 format!("https://{h}:{https}")
155 }
156 } else if is_primary && let Some(ext) = &external_url {
157 ext.clone()
158 } else if let (Some(s), Some(h)) = (scheme.as_deref(), host.as_deref()) {
159 format!("{s}://{h}:{host_port}")
162 } else {
163 format!("http://127.0.0.1:{host_port}")
164 };
165 ctx.insert(format!("service.port_url.{}", p.name), port_url);
166 }
167}
168
169fn build_env_file(
171 home_dir: &std::path::Path,
172 rendered_env: &[EnvVar],
173 resolved_ports: &[(String, u16)],
174) -> GeneratedFile {
175 let mut lines = Vec::new();
176
177 for env in rendered_env {
178 lines.push(format!("{}={}", env.name, env.value));
183 }
184
185 lines.push(format!("SERVICE_HOME={}", home_dir.display()));
187
188 for (name, port) in resolved_ports {
193 let var_name = format!("SERVICE_PORT_{}", name.to_uppercase());
194 lines.push(format!("{var_name}={port}"));
195 }
196
197 GeneratedFile {
198 path: home_dir.join(".env"),
199 content: lines.join("\n") + "\n",
200 }
201}
202
203fn render_env_vars(
206 service_def: &ServiceDef,
207 ctx: &BTreeMap<String, String>,
208 env_overrides: &BTreeMap<String, String>,
209 auth_kind: Option<&AuthKind>,
210 enabled_groups: &BTreeSet<String>,
211) -> Result<Vec<EnvVar>> {
212 let mut rendered: Vec<EnvVar> = service_def
213 .env
214 .iter()
215 .map(|env| render_one(env, env_overrides, ctx, None))
216 .collect::<Result<Vec<_>>>()?;
217
218 for group in &service_def.env_groups {
221 if !enabled_groups.contains(&group.name) {
222 continue;
223 }
224 for env in &group.env {
225 rendered.push(render_one(env, env_overrides, ctx, Some(&group.name))?);
226 }
227 }
228
229 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
230 for (env_name, value_template) in &service_def.mappings.smtp {
231 let value = template::render(value_template, ctx)?;
232 rendered.push(EnvVar {
235 name: env_name.clone(),
236 value,
237 kind: Default::default(),
238 prompt: None,
239 format: Default::default(),
240 length: None,
241 jwt_claims: None,
242 jwt_signing_key: None,
243 });
244 }
245 }
246 if auth_kind.is_some() {
247 for (env_name, value_template) in &service_def.mappings.auth {
248 let value = template::render(value_template, ctx)?;
249 if value.is_empty() {
250 return Err(Error::Template(format!(
251 "auth mapping {env_name} rendered to empty value from template: {value_template}"
252 )));
253 }
254 rendered.push(EnvVar {
255 name: env_name.clone(),
256 value,
257 kind: Default::default(),
258 prompt: None,
259 format: Default::default(),
260 length: None,
261 jwt_claims: None,
262 jwt_signing_key: None,
263 });
264 }
265 }
266
267 Ok(rendered)
268}
269
270fn render_one(
274 env: &EnvVar,
275 env_overrides: &BTreeMap<String, String>,
276 ctx: &BTreeMap<String, String>,
277 group: Option<&str>,
278) -> Result<EnvVar> {
279 let value = match env_overrides.get(&env.name) {
280 Some(override_value) => override_value.clone(),
281 None => {
282 if let Some(group_name) = group
283 && env.kind == EnvKind::Required
284 {
285 return Err(Error::Template(format!(
286 "required env var '{}' in group '{}' has no value — provide it via the interactive prompt or process env",
287 env.name, group_name
288 )));
289 }
290 template::render(&env.value, ctx)?
291 }
292 };
293 Ok(EnvVar {
294 name: env.name.clone(),
295 value,
296 kind: Default::default(),
297 prompt: None,
298 format: Default::default(),
299 length: None,
300 jwt_claims: None,
301 jwt_signing_key: None,
302 })
303}
304
305pub fn extract_secret_refs(value: &str) -> Vec<String> {
306 let mut secrets = Vec::new();
307 let mut rest = value;
308 while let Some(start) = rest.find("{{secret.") {
309 let after = &rest[start + 9..];
310 if let Some(end) = after.find("}}") {
311 secrets.push(after[..end].to_string());
312 rest = &after[end + 2..];
313 } else {
314 break;
315 }
316 }
317 secrets
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::config::schema::Config;
324 use crate::registry::service_def::{
325 EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
326 };
327
328 fn minimal_service_def() -> ServiceDef {
329 ServiceDef {
330 service: ServiceMeta {
331 name: "demo".into(),
332 description: "demo".into(),
333 url: None,
334 kind: Default::default(),
335 architecture: vec![],
336 https: Default::default(),
337 runtime: Default::default(),
338 run: None,
339 build: None,
340 },
341 requirements: None,
342 ports: vec![PortDef {
343 name: "http".into(),
344 container_port: 80,
345 host_port: None,
346 protocol: Default::default(),
347 tailscale_https: None,
348 }],
349 env: vec![
350 EnvVar {
351 name: "HOSTPORT".into(),
352 value: "{{service.port}}".into(),
353 kind: EnvKind::Default,
354 prompt: None,
355 format: Default::default(),
356 length: None,
357 jwt_claims: None,
358 jwt_signing_key: None,
359 },
360 EnvVar {
361 name: "ADMIN_PASSWORD".into(),
362 value: "{{secret.admin}}".into(),
363 kind: EnvKind::Default,
364 prompt: None,
365 format: Default::default(),
366 length: Some(16),
367 jwt_claims: None,
368 jwt_signing_key: None,
369 },
370 ],
371 env_groups: vec![],
372 requires: vec![],
373 mappings: Default::default(),
374 integrations: Default::default(),
375 capabilities: Default::default(),
376 backup: None,
377 }
378 }
379
380 fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
381 EnvVar {
382 name: name.into(),
383 value: value.into(),
384 kind,
385 prompt: None,
386 format: Default::default(),
387 length: None,
388 jwt_claims: None,
389 jwt_signing_key: None,
390 }
391 }
392
393 fn def_with_oauth_group() -> ServiceDef {
394 let mut def = minimal_service_def();
395 def.env_groups.push(EnvGroup {
396 name: "google_oauth".into(),
397 prompt: "Enable Google?".into(),
398 env: vec![
399 plain_env("CLIENT_ID", "", EnvKind::Required),
400 plain_env("CLIENT_SECRET", "", EnvKind::Required),
401 plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
402 plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
403 ],
404 });
405 def
406 }
407
408 fn multiport_def() -> ServiceDef {
411 let mut def = minimal_service_def();
412 def.ports = vec![
413 PortDef {
414 name: "http".into(),
415 container_port: 8080,
416 host_port: None,
417 protocol: Default::default(),
418 tailscale_https: Some(8080),
419 },
420 PortDef {
421 name: "photos".into(),
422 container_port: 3000,
423 host_port: None,
424 protocol: Default::default(),
425 tailscale_https: Some(443),
426 },
427 ];
428 def
429 }
430
431 fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
432 let def = multiport_def();
433 let resolved = vec![
434 ("http".to_string(), 8080u16),
435 ("photos".to_string(), 10002u16),
436 ];
437 let mut ctx = BTreeMap::new();
438 ctx.insert("service.external_url".to_string(), external_url.to_string());
439 insert_port_urls(&mut ctx, &def, &resolved, url);
440 ctx
441 }
442
443 #[test]
444 fn port_url_loopback_uses_host_ports() {
445 let ctx = port_urls(None, "http://127.0.0.1:8080");
447 assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
448 assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
449 }
450
451 #[test]
452 fn port_url_raw_ip_url_exposes_each_port() {
453 let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
455 assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
456 assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
457 }
458
459 #[test]
460 fn port_url_tailscale_splits_root_and_api() {
461 let url = "https://ente-debian.cobbler-tuna.ts.net";
463 let ctx = port_urls(Some(url), url);
464 assert_eq!(
465 ctx["service.port_url.http"],
466 "https://ente-debian.cobbler-tuna.ts.net:8080"
467 );
468 assert_eq!(
469 ctx["service.port_url.photos"],
470 "https://ente-debian.cobbler-tuna.ts.net"
471 );
472 }
473
474 fn gen_with_group(
475 def: &ServiceDef,
476 enabled_groups: &BTreeSet<String>,
477 overrides: &BTreeMap<String, String>,
478 ) -> Result<String> {
479 let config = Config::default();
480 let resolved = vec![("http".to_string(), 10002u16)];
481 let output = generate_env(GenerateEnvParams {
482 config: &config,
483 service_def: def,
484 auth_kind: None,
485 host_port: Some(10002),
486 resolved_ports: &resolved,
487 env_overrides: overrides,
488 exposure: &Exposure::Loopback,
489 extra_env: BTreeMap::new(),
490 pre_built_ctx: None,
491 enable_smtp: false,
492 enabled_groups,
493 })?;
494 Ok(output.env_file.content)
495 }
496
497 #[test]
498 fn env_group_disabled_writes_no_members() {
499 let def = def_with_oauth_group();
500 let no_groups = BTreeSet::new();
501 let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
502 .expect("generate_env should succeed with no groups enabled");
503 for name in [
504 "CLIENT_ID",
505 "CLIENT_SECRET",
506 "CALLBACK_URL",
507 "OAUTH_ENABLED",
508 ] {
509 assert!(
510 !content.contains(&format!("{name}=")),
511 "disabled group member '{name}' leaked into .env: {content}"
512 );
513 }
514 }
515
516 #[test]
517 fn env_group_enabled_writes_all_members() {
518 let def = def_with_oauth_group();
519 let mut enabled = BTreeSet::new();
520 enabled.insert("google_oauth".to_string());
521 let mut overrides = BTreeMap::new();
522 overrides.insert("CLIENT_ID".into(), "my-client".into());
523 overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
524 let content = gen_with_group(&def, &enabled, &overrides)
525 .expect("generate_env should succeed with the group enabled + overrides supplied");
526 assert!(content.contains("CLIENT_ID=my-client"), "{content}");
527 assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
528 assert!(
529 content.contains("CALLBACK_URL=https://demo/cb"),
530 "{content}"
531 );
532 assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
533 }
534
535 #[test]
536 fn env_group_enabled_required_member_without_override_errors() {
537 let def = def_with_oauth_group();
538 let mut enabled = BTreeSet::new();
539 enabled.insert("google_oauth".to_string());
540 let mut overrides = BTreeMap::new();
543 overrides.insert("CLIENT_ID".into(), "my-client".into());
544 let err = gen_with_group(&def, &enabled, &overrides)
545 .expect_err("required member missing must surface as an error");
546 let msg = err.to_string();
547 assert!(
548 msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
549 "error should name the missing member + group: {msg}"
550 );
551 }
552
553 #[test]
560 fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
561 let def = minimal_service_def();
562 let config = Config::default();
563 let prebuilt =
566 context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
567 .expect("build_context with host_port=None should succeed");
568 assert!(!prebuilt.contains_key("service.port"));
569 let admin_secret = prebuilt
570 .get("secret.admin")
571 .expect("secret.admin should have been generated in the prompt phase")
572 .clone();
573
574 let resolved = vec![("http".to_string(), 10002u16)];
576 let no_groups = BTreeSet::new();
577 let output = generate_env(GenerateEnvParams {
578 config: &config,
579 service_def: &def,
580 auth_kind: None,
581 host_port: Some(10002),
582 resolved_ports: &resolved,
583 env_overrides: &BTreeMap::new(),
584 exposure: &Exposure::Loopback,
585 extra_env: BTreeMap::new(),
586 pre_built_ctx: Some(prebuilt),
587 enable_smtp: false,
588 enabled_groups: &no_groups,
589 })
590 .expect("generate_env must succeed with the real host_port");
591
592 assert!(
595 output.env_file.content.contains("HOSTPORT=10002"),
596 ".env missing real port: {}",
597 output.env_file.content,
598 );
599 assert!(
600 output
601 .env_file
602 .content
603 .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
604 "prompt-phase secret not preserved in .env: {}",
605 output.env_file.content,
606 );
607 }
608}