Skip to main content

ryra_core/generate/
mod.rs

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
18/// Parameters for [`generate_env`].
19pub struct GenerateEnvParams<'a> {
20    pub config: &'a Config,
21    pub service_def: &'a ServiceDef,
22    /// The auth kind the user chose to enable, if any.
23    pub auth_kind: Option<&'a AuthKind>,
24    /// Primary host port (for `service.url` / `service.port` templating).
25    pub host_port: Option<u16>,
26    /// Per-port resolved host ports, keyed by port name (e.g. "http", "smtp").
27    /// Used to emit `PORT_*` lines in the .env file — each entry here
28    /// corresponds to one `[[ports]]` definition in service.toml.
29    pub resolved_ports: &'a [(String, u16)],
30    pub env_overrides: &'a BTreeMap<String, String>,
31    /// How the service is exposed; its URL (if any) feeds templates like
32    /// `{{service.external_url}}` / `{{service.domain}}`.
33    pub exposure: &'a Exposure,
34    /// Additional env vars to append to the .env file (e.g., CA cert trust vars).
35    pub extra_env: BTreeMap<String, String>,
36    /// Pre-built template context. When provided, secrets and auth credentials
37    /// from this context are reused instead of generating fresh ones. This
38    /// ensures the values shown during interactive prompts match what gets
39    /// written to the .env file.
40    pub pre_built_ctx: Option<BTreeMap<String, String>>,
41    /// Whether this service should use the globally configured SMTP. When
42    /// false, smtp.* is left out of the context and [mappings.smtp] is skipped
43    /// — lets the user opt a single service out of email without clearing the
44    /// global SMTP config.
45    pub enable_smtp: bool,
46    /// Names of `[[env_group]]` entries the user toggled on. Members of
47    /// groups not listed here are fully omitted from the generated `.env`.
48    pub enabled_groups: &'a BTreeSet<String>,
49}
50
51/// Result of generating env for a service.
52pub struct EnvOutput {
53    pub env_file: GeneratedFile,
54    /// The template context used during generation (for auth registration, etc.).
55    pub ctx: BTreeMap<String, String>,
56}
57
58/// Generate the .env file for a service based on its definition and context.
59pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
60    let name = &params.service_def.service.name;
61
62    // Always build a fresh context with the now-known host_port. Overlay
63    // secret.* and auth.* entries from the pre-built context so randomly
64    // generated values the user saw during prompts stay stable.
65    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    // Build .env file content
96    let home_dir = crate::service_home(name)?;
97    let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
98
99    // Append extra env vars (e.g., CA cert trust for OIDC)
100    for (key, value) in &params.extra_env {
101        env_file.content.push_str(&format!("{key}={value}\n"));
102    }
103
104    Ok(EnvOutput { env_file, ctx })
105}
106
107/// Insert `service.port_url.<name>` for every declared port — the URL at
108/// which that specific port is reachable from a browser.
109///
110/// For single-endpoint services every port resolves to `external_url`.
111/// Multi-port services (ente: web UI on 443, API on 8080, served on separate
112/// Tailscale HTTPS ports) get a distinct URL per port, so a template like
113/// `ENTE_API_ORIGIN = {{service.port_url.http}}` points at the API endpoint
114/// while the bare hostname serves the UI — in every exposure mode (loopback,
115/// raw `--url`, or `--tailscale`).
116fn insert_port_urls(
117    ctx: &mut BTreeMap<String, String>,
118    service_def: &ServiceDef,
119    resolved_ports: &[(String, u16)],
120    url: Option<&str>,
121) {
122    // The primary port (named "http", else the first) answers at the root
123    // URL — for it, `port_url` is exactly `external_url` outside Tailscale.
124    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                // Tailscale: this port answers at the service hostname on its
150                // HTTPS port (443 is the bare hostname, no explicit port).
151                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                // Non-primary port under a raw `--url`: directly published at the
160                // same host on its own host port.
161                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
169/// Build the .env file for a service.
170fn 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        // Write raw KEY=VALUE for podman --env-file. Podman does NOT strip
179        // quotes (single or double), so any shell-style quoting ends up as
180        // literal characters in the container. Tests that source the .env
181        // must stick to values that survive unquoted bash parsing.
182        lines.push(format!("{}={}", env.name, env.value));
183    }
184
185    // Expose service home path so scripts can reference it
186    lines.push(format!("SERVICE_HOME={}", home_dir.display()));
187
188    // Expose each [[ports]] entry as SERVICE_PORT_<NAME> with its
189    // resolved host port. The SERVICE_ prefix matches SERVICE_HOME and
190    // makes ryra-emitted vars visually distinct from service-specific
191    // ones (which carry their own naming, e.g. POSTGRES_PASSWORD).
192    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
203// --- Shared helpers ---
204
205fn 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    // Append members of every enabled `[[env_group]]`. Groups not toggled
219    // on are fully omitted — no partial state possible.
220    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            // Empty values are valid — e.g., inbucket doesn't need username/password.
233            // Static values (no template) are always included as-is.
234            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
270/// Render a single `EnvVar` — apply an override if present, otherwise run
271/// the template. Required group members without an override are a hard
272/// error so the service never starts with half of a group configured.
273fn 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    /// Two-endpoint service like ente: museum API ("http") served over
411    /// Tailscale on :8080, Photos UI ("photos") at the bare hostname (:443).
412    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        // No --url: primary == external_url (localhost), others at 127.0.0.1:<port>.
448        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        // Raw --url at a tailnet IP: museum == the url, photos directly published.
456        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        // --tailscale: photos answers at the bare hostname, museum on :8080.
464        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        // Intentionally leave CLIENT_SECRET out — required members with no
543        // value must fail loudly, never produce an empty .env entry.
544        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    /// Regression: when the interactive CLI builds `pre_built_ctx` with
556    /// `host_port: None` (the real port isn't allocated yet), `generate_env`
557    /// must still produce a valid env file with the real `service.port`.
558    /// Previously the pre-built ctx was reused wholesale, so any env value
559    /// referencing `{{service.port}}` failed strict-mode rendering with
560    /// "undefined value".
561    #[test]
562    fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
563        let def = minimal_service_def();
564        let config = Config::default();
565        // Build the pre-built ctx as the interactive prompt phase does:
566        // host_port is None, so `service.port` is absent from the ctx.
567        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        // Now run generate_env with the real allocated host_port.
577        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        // The resulting .env must carry the allocated port, and the randomly
595        // generated secret from the prompt phase must be preserved verbatim.
596        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}