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 post_install: None,
341 },
342 requirements: None,
343 ports: vec![PortDef {
344 name: "http".into(),
345 container_port: 80,
346 host_port: None,
347 protocol: Default::default(),
348 tailscale_https: None,
349 }],
350 env: vec![
351 EnvVar {
352 name: "HOSTPORT".into(),
353 value: "{{service.port}}".into(),
354 kind: EnvKind::Default,
355 prompt: None,
356 format: Default::default(),
357 length: None,
358 jwt_claims: None,
359 jwt_signing_key: None,
360 },
361 EnvVar {
362 name: "ADMIN_PASSWORD".into(),
363 value: "{{secret.admin}}".into(),
364 kind: EnvKind::Default,
365 prompt: None,
366 format: Default::default(),
367 length: Some(16),
368 jwt_claims: None,
369 jwt_signing_key: None,
370 },
371 ],
372 env_groups: vec![],
373 requires: vec![],
374 mappings: Default::default(),
375 integrations: Default::default(),
376 capabilities: Default::default(),
377 backup: None,
378 metrics: None,
379 }
380 }
381
382 fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
383 EnvVar {
384 name: name.into(),
385 value: value.into(),
386 kind,
387 prompt: None,
388 format: Default::default(),
389 length: None,
390 jwt_claims: None,
391 jwt_signing_key: None,
392 }
393 }
394
395 fn def_with_oauth_group() -> ServiceDef {
396 let mut def = minimal_service_def();
397 def.env_groups.push(EnvGroup {
398 name: "google_oauth".into(),
399 prompt: "Enable Google?".into(),
400 env: vec![
401 plain_env("CLIENT_ID", "", EnvKind::Required),
402 plain_env("CLIENT_SECRET", "", EnvKind::Required),
403 plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
404 plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
405 ],
406 });
407 def
408 }
409
410 fn multiport_def() -> ServiceDef {
413 let mut def = minimal_service_def();
414 def.ports = vec![
415 PortDef {
416 name: "http".into(),
417 container_port: 8080,
418 host_port: None,
419 protocol: Default::default(),
420 tailscale_https: Some(8080),
421 },
422 PortDef {
423 name: "photos".into(),
424 container_port: 3000,
425 host_port: None,
426 protocol: Default::default(),
427 tailscale_https: Some(443),
428 },
429 ];
430 def
431 }
432
433 fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
434 let def = multiport_def();
435 let resolved = vec![
436 ("http".to_string(), 8080u16),
437 ("photos".to_string(), 10002u16),
438 ];
439 let mut ctx = BTreeMap::new();
440 ctx.insert("service.external_url".to_string(), external_url.to_string());
441 insert_port_urls(&mut ctx, &def, &resolved, url);
442 ctx
443 }
444
445 #[test]
446 fn port_url_loopback_uses_host_ports() {
447 let ctx = port_urls(None, "http://127.0.0.1:8080");
449 assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
450 assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
451 }
452
453 #[test]
454 fn port_url_raw_ip_url_exposes_each_port() {
455 let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
457 assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
458 assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
459 }
460
461 #[test]
462 fn port_url_tailscale_splits_root_and_api() {
463 let url = "https://ente-debian.cobbler-tuna.ts.net";
465 let ctx = port_urls(Some(url), url);
466 assert_eq!(
467 ctx["service.port_url.http"],
468 "https://ente-debian.cobbler-tuna.ts.net:8080"
469 );
470 assert_eq!(
471 ctx["service.port_url.photos"],
472 "https://ente-debian.cobbler-tuna.ts.net"
473 );
474 }
475
476 fn gen_with_group(
477 def: &ServiceDef,
478 enabled_groups: &BTreeSet<String>,
479 overrides: &BTreeMap<String, String>,
480 ) -> Result<String> {
481 let config = Config::default();
482 let resolved = vec![("http".to_string(), 10002u16)];
483 let output = generate_env(GenerateEnvParams {
484 config: &config,
485 service_def: def,
486 auth_kind: None,
487 host_port: Some(10002),
488 resolved_ports: &resolved,
489 env_overrides: overrides,
490 exposure: &Exposure::Loopback,
491 extra_env: BTreeMap::new(),
492 pre_built_ctx: None,
493 enable_smtp: false,
494 enabled_groups,
495 })?;
496 Ok(output.env_file.content)
497 }
498
499 #[test]
500 fn env_group_disabled_writes_no_members() {
501 let def = def_with_oauth_group();
502 let no_groups = BTreeSet::new();
503 let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
504 .expect("generate_env should succeed with no groups enabled");
505 for name in [
506 "CLIENT_ID",
507 "CLIENT_SECRET",
508 "CALLBACK_URL",
509 "OAUTH_ENABLED",
510 ] {
511 assert!(
512 !content.contains(&format!("{name}=")),
513 "disabled group member '{name}' leaked into .env: {content}"
514 );
515 }
516 }
517
518 #[test]
519 fn env_group_enabled_writes_all_members() {
520 let def = def_with_oauth_group();
521 let mut enabled = BTreeSet::new();
522 enabled.insert("google_oauth".to_string());
523 let mut overrides = BTreeMap::new();
524 overrides.insert("CLIENT_ID".into(), "my-client".into());
525 overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
526 let content = gen_with_group(&def, &enabled, &overrides)
527 .expect("generate_env should succeed with the group enabled + overrides supplied");
528 assert!(content.contains("CLIENT_ID=my-client"), "{content}");
529 assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
530 assert!(
531 content.contains("CALLBACK_URL=https://demo/cb"),
532 "{content}"
533 );
534 assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
535 }
536
537 #[test]
538 fn env_group_enabled_required_member_without_override_errors() {
539 let def = def_with_oauth_group();
540 let mut enabled = BTreeSet::new();
541 enabled.insert("google_oauth".to_string());
542 let mut overrides = BTreeMap::new();
545 overrides.insert("CLIENT_ID".into(), "my-client".into());
546 let err = gen_with_group(&def, &enabled, &overrides)
547 .expect_err("required member missing must surface as an error");
548 let msg = err.to_string();
549 assert!(
550 msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
551 "error should name the missing member + group: {msg}"
552 );
553 }
554
555 #[test]
562 fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
563 let def = minimal_service_def();
564 let config = Config::default();
565 let prebuilt =
568 context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
569 .expect("build_context with host_port=None should succeed");
570 assert!(!prebuilt.contains_key("service.port"));
571 let admin_secret = prebuilt
572 .get("secret.admin")
573 .expect("secret.admin should have been generated in the prompt phase")
574 .clone();
575
576 let resolved = vec![("http".to_string(), 10002u16)];
578 let no_groups = BTreeSet::new();
579 let output = generate_env(GenerateEnvParams {
580 config: &config,
581 service_def: &def,
582 auth_kind: None,
583 host_port: Some(10002),
584 resolved_ports: &resolved,
585 env_overrides: &BTreeMap::new(),
586 exposure: &Exposure::Loopback,
587 extra_env: BTreeMap::new(),
588 pre_built_ctx: Some(prebuilt),
589 enable_smtp: false,
590 enabled_groups: &no_groups,
591 })
592 .expect("generate_env must succeed with the real host_port");
593
594 assert!(
597 output.env_file.content.contains("HOSTPORT=10002"),
598 ".env missing real port: {}",
599 output.env_file.content,
600 );
601 assert!(
602 output
603 .env_file
604 .content
605 .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
606 "prompt-phase secret not preserved in .env: {}",
607 output.env_file.content,
608 );
609 }
610}