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