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