pub mod bundle;
pub mod context;
pub mod template;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::config::schema::Config;
use crate::error::{Error, Result};
use crate::exposure::Exposure;
use crate::registry::service_def::{AuthKind, EnvKind, EnvVar, PortDef, ServiceDef};
#[derive(Debug)]
pub struct GeneratedFile {
pub path: PathBuf,
pub content: String,
}
pub struct GenerateEnvParams<'a> {
pub config: &'a Config,
pub service_def: &'a ServiceDef,
pub auth_kind: Option<&'a AuthKind>,
pub host_port: Option<u16>,
pub resolved_ports: &'a [(String, u16)],
pub env_overrides: &'a BTreeMap<String, String>,
pub exposure: &'a Exposure,
pub extra_env: BTreeMap<String, String>,
pub pre_built_ctx: Option<BTreeMap<String, String>>,
pub enable_smtp: bool,
pub enabled_groups: &'a BTreeSet<String>,
pub selected_choices: &'a BTreeMap<String, String>,
}
pub struct EnvOutput {
pub env_file: GeneratedFile,
pub ctx: BTreeMap<String, String>,
}
pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
let name = ¶ms.service_def.service.name;
let mut ctx = context::build_context(
params.config,
params.service_def,
params.host_port,
params.auth_kind,
params.exposure,
params.enable_smtp,
)?;
if let Some(prebuilt) = params.pre_built_ctx {
for (key, value) in prebuilt {
if key.starts_with("secret.") || key.starts_with("auth.") {
ctx.insert(key, value);
}
}
}
let mut eff_ports: Vec<&PortDef> = params.service_def.ports.iter().collect();
for choice in ¶ms.service_def.choices {
let sel = params
.selected_choices
.get(&choice.name)
.unwrap_or(&choice.default);
if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
eff_ports.extend(opt.ports.iter());
}
}
insert_port_urls(
&mut ctx,
&eff_ports,
params.resolved_ports,
params.exposure.url(),
);
let rendered_env = render_env_vars(
params.service_def,
&ctx,
params.env_overrides,
params.auth_kind,
params.enabled_groups,
params.selected_choices,
)?;
let home_dir = crate::service_home(name)?;
let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
for (key, value) in ¶ms.extra_env {
env_file.content.push_str(&format!("{key}={value}\n"));
}
Ok(EnvOutput { env_file, ctx })
}
fn insert_port_urls(
ctx: &mut BTreeMap<String, String>,
ports: &[&PortDef],
resolved_ports: &[(String, u16)],
url: Option<&str>,
) {
for (name, port) in resolved_ports {
ctx.insert(format!("service.ports.{name}"), port.to_string());
}
let primary = ports
.iter()
.copied()
.find(|p| p.name.eq_ignore_ascii_case("http"))
.or_else(|| ports.first().copied())
.map(|p| p.name.clone());
let parsed = url.and_then(|u| url::Url::parse(u).ok());
let host = parsed
.as_ref()
.and_then(|u| u.host_str())
.map(str::to_string);
let scheme = parsed.as_ref().map(|u| u.scheme().to_string());
let is_ts = host.as_deref().is_some_and(|h| h.ends_with(".ts.net"));
let external_url = ctx.get("service.external_url").cloned();
for p in ports.iter().copied() {
let host_port = resolved_ports
.iter()
.find(|(n, _)| n == &p.name)
.map(|(_, hp)| *hp)
.or(p.host_port)
.unwrap_or(p.container_port);
let is_primary = primary.as_deref() == Some(p.name.as_str());
let port_url =
if let (true, Some(https), Some(h)) = (is_ts, p.tailscale_https, host.as_deref()) {
if https == 443 {
format!("https://{h}")
} else {
format!("https://{h}:{https}")
}
} else if is_primary && let Some(ext) = &external_url {
ext.clone()
} else if let (Some(s), Some(h)) = (scheme.as_deref(), host.as_deref()) {
format!("{s}://{h}:{host_port}")
} else {
format!("http://127.0.0.1:{host_port}")
};
ctx.insert(format!("service.port_url.{}", p.name), port_url);
}
}
fn build_env_file(
home_dir: &std::path::Path,
rendered_env: &[EnvVar],
resolved_ports: &[(String, u16)],
) -> GeneratedFile {
let mut lines = Vec::new();
for env in rendered_env {
lines.push(format!("{}={}", env.name, env.value));
}
lines.push(format!("SERVICE_HOME={}", home_dir.display()));
for (name, port) in resolved_ports {
let var_name = format!("SERVICE_PORT_{}", name.to_uppercase());
lines.push(format!("{var_name}={port}"));
}
GeneratedFile {
path: home_dir.join(".env"),
content: lines.join("\n") + "\n",
}
}
fn render_env_vars(
service_def: &ServiceDef,
ctx: &BTreeMap<String, String>,
env_overrides: &BTreeMap<String, String>,
auth_kind: Option<&AuthKind>,
enabled_groups: &BTreeSet<String>,
selected_choices: &BTreeMap<String, String>,
) -> Result<Vec<EnvVar>> {
let mut rendered: Vec<EnvVar> = service_def
.env
.iter()
.map(|env| render_one(env, env_overrides, ctx, None))
.collect::<Result<Vec<_>>>()?;
for group in &service_def.env_groups {
if !enabled_groups.contains(&group.name) {
continue;
}
let loc = format!("group '{}'", group.name);
for env in &group.env {
rendered.push(render_one(env, env_overrides, ctx, Some(&loc))?);
}
}
for choice in &service_def.choices {
let selected = selected_choices
.get(&choice.name)
.unwrap_or(&choice.default);
let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
continue;
};
let loc = format!("choice '{}' option '{}'", choice.name, option.name);
for env in &option.env {
rendered.push(render_one(env, env_overrides, ctx, Some(&loc))?);
}
}
if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
for (env_name, value_template) in &service_def.mappings.smtp {
let value = template::render(value_template, ctx)?;
rendered.push(EnvVar {
name: env_name.clone(),
value,
kind: Default::default(),
prompt: None,
format: Default::default(),
length: None,
jwt_claims: None,
jwt_signing_key: None,
});
}
}
if auth_kind.is_some() {
for (env_name, value_template) in &service_def.mappings.auth {
let value = template::render(value_template, ctx)?;
if value.is_empty() {
return Err(Error::Template(format!(
"auth mapping {env_name} rendered to empty value from template: {value_template}"
)));
}
rendered.push(EnvVar {
name: env_name.clone(),
value,
kind: Default::default(),
prompt: None,
format: Default::default(),
length: None,
jwt_claims: None,
jwt_signing_key: None,
});
}
}
Ok(rendered)
}
fn render_one(
env: &EnvVar,
env_overrides: &BTreeMap<String, String>,
ctx: &BTreeMap<String, String>,
member_of: Option<&str>,
) -> Result<EnvVar> {
let value = match env_overrides.get(&env.name) {
Some(override_value) => override_value.clone(),
None => {
if let Some(loc) = member_of
&& env.kind == EnvKind::Required
{
return Err(Error::Template(format!(
"required env var '{}' in {loc} has no value; provide it via the interactive prompt or process env",
env.name
)));
}
template::render(&env.value, ctx)?
}
};
Ok(EnvVar {
name: env.name.clone(),
value,
kind: Default::default(),
prompt: None,
format: Default::default(),
length: None,
jwt_claims: None,
jwt_signing_key: None,
})
}
pub fn extract_secret_refs(value: &str) -> Vec<String> {
let mut secrets = Vec::new();
let mut rest = value;
while let Some(start) = rest.find("{{secret.") {
let after = &rest[start + 9..];
if let Some(end) = after.find("}}") {
secrets.push(after[..end].to_string());
rest = &after[end + 2..];
} else {
break;
}
}
secrets
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::Config;
use crate::registry::service_def::{
EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
};
fn minimal_service_def() -> ServiceDef {
ServiceDef {
service: ServiceMeta {
name: "demo".into(),
description: "demo".into(),
url: None,
kind: Default::default(),
architecture: vec![],
https: Default::default(),
runtime: Default::default(),
run: None,
build: None,
post_install: None,
},
requirements: None,
ports: vec![PortDef {
name: "http".into(),
container_port: 80,
host_port: None,
protocol: Default::default(),
tailscale_https: None,
}],
env: vec![
EnvVar {
name: "HOSTPORT".into(),
value: "{{service.port}}".into(),
kind: EnvKind::Default,
prompt: None,
format: Default::default(),
length: None,
jwt_claims: None,
jwt_signing_key: None,
},
EnvVar {
name: "ADMIN_PASSWORD".into(),
value: "{{secret.admin}}".into(),
kind: EnvKind::Default,
prompt: None,
format: Default::default(),
length: Some(16),
jwt_claims: None,
jwt_signing_key: None,
},
],
env_groups: vec![],
choices: vec![],
requires: vec![],
mappings: Default::default(),
integrations: Default::default(),
capabilities: Default::default(),
backup: None,
metrics: None,
}
}
fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
EnvVar {
name: name.into(),
value: value.into(),
kind,
prompt: None,
format: Default::default(),
length: None,
jwt_claims: None,
jwt_signing_key: None,
}
}
fn def_with_oauth_group() -> ServiceDef {
let mut def = minimal_service_def();
def.env_groups.push(EnvGroup {
name: "google_oauth".into(),
prompt: "Enable Google?".into(),
env: vec![
plain_env("CLIENT_ID", "", EnvKind::Required),
plain_env("CLIENT_SECRET", "", EnvKind::Required),
plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
],
});
def
}
fn multiport_def() -> ServiceDef {
let mut def = minimal_service_def();
def.ports = vec![
PortDef {
name: "http".into(),
container_port: 8080,
host_port: None,
protocol: Default::default(),
tailscale_https: Some(8080),
},
PortDef {
name: "photos".into(),
container_port: 3000,
host_port: None,
protocol: Default::default(),
tailscale_https: Some(443),
},
];
def
}
fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
let def = multiport_def();
let resolved = vec![
("http".to_string(), 8080u16),
("photos".to_string(), 10002u16),
];
let mut ctx = BTreeMap::new();
ctx.insert("service.external_url".to_string(), external_url.to_string());
let ports: Vec<&PortDef> = def.ports.iter().collect();
insert_port_urls(&mut ctx, &ports, &resolved, url);
ctx
}
#[test]
fn port_url_loopback_uses_host_ports() {
let ctx = port_urls(None, "http://127.0.0.1:8080");
assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
}
#[test]
fn port_url_raw_ip_url_exposes_each_port() {
let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
}
#[test]
fn port_url_tailscale_splits_root_and_api() {
let url = "https://ente-debian.cobbler-tuna.ts.net";
let ctx = port_urls(Some(url), url);
assert_eq!(
ctx["service.port_url.http"],
"https://ente-debian.cobbler-tuna.ts.net:8080"
);
assert_eq!(
ctx["service.port_url.photos"],
"https://ente-debian.cobbler-tuna.ts.net"
);
}
fn gen_with_group(
def: &ServiceDef,
enabled_groups: &BTreeSet<String>,
overrides: &BTreeMap<String, String>,
) -> Result<String> {
let config = Config::default();
let resolved = vec![("http".to_string(), 10002u16)];
let output = generate_env(GenerateEnvParams {
config: &config,
service_def: def,
auth_kind: None,
host_port: Some(10002),
resolved_ports: &resolved,
env_overrides: overrides,
exposure: &Exposure::Loopback,
extra_env: BTreeMap::new(),
pre_built_ctx: None,
enable_smtp: false,
enabled_groups,
selected_choices: &BTreeMap::new(),
})?;
Ok(output.env_file.content)
}
fn gen_with_choices(
def: &ServiceDef,
selected: &BTreeMap<String, String>,
overrides: &BTreeMap<String, String>,
) -> Result<String> {
let config = Config::default();
let resolved = vec![("http".to_string(), 10002u16)];
let output = generate_env(GenerateEnvParams {
config: &config,
service_def: def,
auth_kind: None,
host_port: Some(10002),
resolved_ports: &resolved,
env_overrides: overrides,
exposure: &Exposure::Loopback,
extra_env: BTreeMap::new(),
pre_built_ctx: None,
enable_smtp: false,
enabled_groups: &BTreeSet::new(),
selected_choices: selected,
})?;
Ok(output.env_file.content)
}
fn def_with_billing_choice() -> ServiceDef {
toml::from_str(
r#"
[service]
name = "billed"
description = "x"
[[ports]]
name = "http"
container_port = 8080
[[choice]]
name = "billing"
prompt = "Billing mode"
default = "mock"
[[choice.option]]
name = "live"
[[choice.option.env]]
name = "BILLING_MODE"
value = "live"
[[choice.option.env]]
name = "STRIPE_SECRET_KEY"
value = ""
kind = "required"
[[choice.option]]
name = "mock"
[[choice.option.env]]
name = "BILLING_MODE"
value = "mock"
"#,
)
.expect("parse")
}
#[test]
fn choice_writes_only_selected_option_members() {
let def = def_with_billing_choice();
let mut selected = BTreeMap::new();
selected.insert("billing".to_string(), "mock".to_string());
let content =
gen_with_choices(&def, &selected, &BTreeMap::new()).expect("mock selection renders");
assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
assert!(!content.contains("STRIPE_SECRET_KEY"), "got: {content}");
}
#[test]
fn choice_option_secret_is_generated() {
let def = toml::from_str::<ServiceDef>(
r#"
[service]
name = "s"
description = "x"
[[ports]]
name = "http"
container_port = 8080
[[choice]]
name = "database"
prompt = "Database"
default = "internal"
[[choice.option]]
name = "internal"
[[choice.option.env]]
name = "DB_PASSWORD"
value = "{{secret.db_password}}"
[[choice.option]]
name = "external"
[[choice.option.env]]
name = "DB_PASSWORD"
value = ""
kind = "required"
"#,
)
.expect("parse");
let mut selected = BTreeMap::new();
selected.insert("database".to_string(), "internal".to_string());
let content = gen_with_choices(&def, &selected, &BTreeMap::new())
.expect("renders with generated secret");
let line = content
.lines()
.find(|l| l.starts_with("DB_PASSWORD="))
.expect("DB_PASSWORD present");
let val = line.trim_start_matches("DB_PASSWORD=");
assert!(!val.is_empty() && !val.contains("{{"), "got: {line}");
}
#[test]
fn choice_falls_back_to_default_when_unselected() {
let def = def_with_billing_choice();
let content = gen_with_choices(&def, &BTreeMap::new(), &BTreeMap::new())
.expect("default selection renders");
assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
}
#[test]
fn choice_required_member_needs_a_value() {
let def = def_with_billing_choice();
let mut selected = BTreeMap::new();
selected.insert("billing".to_string(), "live".to_string());
let err = gen_with_choices(&def, &selected, &BTreeMap::new())
.expect_err("required member without value must fail");
assert!(
format!("{err}").contains("STRIPE_SECRET_KEY"),
"error names the missing var: {err}"
);
}
#[test]
fn choice_required_member_value_is_written() {
let def = def_with_billing_choice();
let mut selected = BTreeMap::new();
selected.insert("billing".to_string(), "live".to_string());
let mut overrides = BTreeMap::new();
overrides.insert("STRIPE_SECRET_KEY".to_string(), "sk_test_123".to_string());
let content = gen_with_choices(&def, &selected, &overrides).expect("live renders");
assert!(content.contains("BILLING_MODE=live"), "got: {content}");
assert!(
content.contains("STRIPE_SECRET_KEY=sk_test_123"),
"got: {content}"
);
}
#[test]
fn env_group_disabled_writes_no_members() {
let def = def_with_oauth_group();
let no_groups = BTreeSet::new();
let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
.expect("generate_env should succeed with no groups enabled");
for name in [
"CLIENT_ID",
"CLIENT_SECRET",
"CALLBACK_URL",
"OAUTH_ENABLED",
] {
assert!(
!content.contains(&format!("{name}=")),
"disabled group member '{name}' leaked into .env: {content}"
);
}
}
#[test]
fn env_group_enabled_writes_all_members() {
let def = def_with_oauth_group();
let mut enabled = BTreeSet::new();
enabled.insert("google_oauth".to_string());
let mut overrides = BTreeMap::new();
overrides.insert("CLIENT_ID".into(), "my-client".into());
overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
let content = gen_with_group(&def, &enabled, &overrides)
.expect("generate_env should succeed with the group enabled + overrides supplied");
assert!(content.contains("CLIENT_ID=my-client"), "{content}");
assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
assert!(
content.contains("CALLBACK_URL=https://demo/cb"),
"{content}"
);
assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
}
#[test]
fn env_group_enabled_required_member_without_override_errors() {
let def = def_with_oauth_group();
let mut enabled = BTreeSet::new();
enabled.insert("google_oauth".to_string());
let mut overrides = BTreeMap::new();
overrides.insert("CLIENT_ID".into(), "my-client".into());
let err = gen_with_group(&def, &enabled, &overrides)
.expect_err("required member missing must surface as an error");
let msg = err.to_string();
assert!(
msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
"error should name the missing member + group: {msg}"
);
}
#[test]
fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
let def = minimal_service_def();
let config = Config::default();
let prebuilt =
context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
.expect("build_context with host_port=None should succeed");
assert!(!prebuilt.contains_key("service.port"));
let admin_secret = prebuilt
.get("secret.admin")
.expect("secret.admin should have been generated in the prompt phase")
.clone();
let resolved = vec![("http".to_string(), 10002u16)];
let no_groups = BTreeSet::new();
let output = generate_env(GenerateEnvParams {
config: &config,
service_def: &def,
auth_kind: None,
host_port: Some(10002),
resolved_ports: &resolved,
env_overrides: &BTreeMap::new(),
exposure: &Exposure::Loopback,
extra_env: BTreeMap::new(),
pre_built_ctx: Some(prebuilt),
enable_smtp: false,
enabled_groups: &no_groups,
selected_choices: &BTreeMap::new(),
})
.expect("generate_env must succeed with the real host_port");
assert!(
output.env_file.content.contains("HOSTPORT=10002"),
".env missing real port: {}",
output.env_file.content,
);
assert!(
output
.env_file
.content
.contains(&format!("ADMIN_PASSWORD={admin_secret}")),
"prompt-phase secret not preserved in .env: {}",
output.env_file.content,
);
}
}