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            },
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    /// Two-endpoint service like ente: museum API ("http") served over
409    /// Tailscale on :8080, Photos UI ("photos") at the bare hostname (:443).
410    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        // No --url: primary == external_url (localhost), others at 127.0.0.1:<port>.
446        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        // Raw --url at a tailnet IP: museum == the url, photos directly published.
454        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        // --tailscale: photos answers at the bare hostname, museum on :8080.
462        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        // Intentionally leave CLIENT_SECRET out — required members with no
541        // value must fail loudly, never produce an empty .env entry.
542        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    /// Regression: when the interactive CLI builds `pre_built_ctx` with
554    /// `host_port: None` (the real port isn't allocated yet), `generate_env`
555    /// must still produce a valid env file with the real `service.port`.
556    /// Previously the pre-built ctx was reused wholesale, so any env value
557    /// referencing `{{service.port}}` failed strict-mode rendering with
558    /// "undefined value".
559    #[test]
560    fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
561        let def = minimal_service_def();
562        let config = Config::default();
563        // Build the pre-built ctx as the interactive prompt phase does:
564        // host_port is None, so `service.port` is absent from the ctx.
565        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        // Now run generate_env with the real allocated host_port.
575        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        // The resulting .env must carry the allocated port, and the randomly
593        // generated secret from the prompt phase must be preserved verbatim.
594        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}