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