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