use std::path::Path;
use super::schema::{LogFormatStr, RecipeError, SecretsBackend, ServiceKind, SetupRecipe, VtaMode};
pub fn load_recipe(path: &Path) -> Result<SetupRecipe, RecipeError> {
let raw = std::fs::read_to_string(path).map_err(|source| RecipeError::Io {
path: path.to_path_buf(),
source,
})?;
let mut recipe: SetupRecipe = toml::from_str(&raw)?;
apply_env_overrides(&mut recipe);
recipe.validate()?;
Ok(recipe)
}
pub fn apply_env_overrides(recipe: &mut SetupRecipe) {
let prefix = match recipe.deployment.service {
ServiceKind::Daemon => "DAEMON",
ServiceKind::Server => "WEBVH",
ServiceKind::Control => "CONTROL",
ServiceKind::Witness => "WITNESS",
ServiceKind::Watcher => "WATCHER",
};
if let Some(v) = env_get(prefix, "PUBLIC_URL") {
recipe.identity.public_url = Some(v);
}
if let Some(v) = env_get(prefix, "DID_HOSTING_URL") {
recipe.identity.did_hosting_url = Some(v);
}
if let Some(v) = env_get(prefix, "MEDIATOR_DID") {
recipe.identity.mediator_did = Some(v);
}
if let Some(v) = env_get(prefix, "CONTROL_DID") {
recipe.identity.control_did = Some(v);
}
if let Some(v) = env_get(prefix, "CONTROL_URL") {
recipe.identity.control_url = Some(v);
}
if let Some(v) = env_get(prefix, "VTA_DID") {
recipe.vta.did = Some(v);
}
if let Some(v) = env_get(prefix, "VTA_CONTEXT_ID") {
recipe.vta.context_id = Some(v);
}
if std::env::var(format!("{prefix}_SECRETS_AWS_SECRET_NAME")).is_ok() {
recipe.secrets.backend = Some(SecretsBackend::Aws);
} else if std::env::var(format!("{prefix}_SECRETS_GCP_SECRET_NAME")).is_ok() {
recipe.secrets.backend = Some(SecretsBackend::Gcp);
} else if std::env::var(format!("{prefix}_SECRETS_AZURE_SECRET_NAME")).is_ok() {
recipe.secrets.backend = Some(SecretsBackend::Azure);
} else if std::env::var(format!("{prefix}_SECRETS_VAULT_ADDR")).is_ok() {
recipe.secrets.backend = Some(SecretsBackend::Vault);
} else if std::env::var(format!("{prefix}_SECRETS_K8S_SECRET_NAME")).is_ok() {
recipe.secrets.backend = Some(SecretsBackend::K8s);
} else if std::env::var(format!("{prefix}_SECRETS_KEYRING_SERVICE")).is_ok() {
recipe.secrets.backend = Some(SecretsBackend::Keyring);
}
if let Some(v) = env_get(prefix, "SECRETS_KEYRING_SERVICE") {
recipe.secrets.keyring_service = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_AWS_REGION") {
recipe.secrets.aws_region = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_AWS_SECRET_NAME") {
recipe.secrets.aws_secret_name = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_GCP_PROJECT") {
recipe.secrets.gcp_project = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_GCP_SECRET_NAME") {
recipe.secrets.gcp_secret_name = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_AZURE_VAULT_URL") {
recipe.secrets.azure_vault_url = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_AZURE_SECRET_NAME") {
recipe.secrets.azure_secret_name = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_ADDR") {
recipe.secrets.vault_addr = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_KV_MOUNT") {
recipe.secrets.vault_kv_mount = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_SECRET_PATH") {
recipe.secrets.vault_secret_path = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_SECRET_KEY") {
recipe.secrets.vault_secret_key = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_NAMESPACE") {
recipe.secrets.vault_namespace = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_AUTH_METHOD") {
recipe.secrets.vault_auth_method = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_K8S_ROLE") {
recipe.secrets.vault_k8s_role = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_K8S_MOUNT") {
recipe.secrets.vault_k8s_mount = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_K8S_JWT_PATH") {
recipe.secrets.vault_k8s_jwt_path = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_TOKEN") {
recipe.secrets.vault_token = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_APPROLE_ROLE_ID") {
recipe.secrets.vault_approle_role_id = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_APPROLE_SECRET_ID") {
recipe.secrets.vault_approle_secret_id = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_APPROLE_MOUNT") {
recipe.secrets.vault_approle_mount = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_VAULT_SKIP_VERIFY") {
recipe.secrets.vault_skip_verify = Some(v == "1" || v.eq_ignore_ascii_case("true"));
}
if let Some(v) = env_get(prefix, "SECRETS_K8S_SECRET_NAME") {
recipe.secrets.k8s_secret_name = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_K8S_NAMESPACE") {
recipe.secrets.k8s_namespace = Some(v);
}
if let Some(v) = env_get(prefix, "SECRETS_K8S_SECRET_KEY") {
recipe.secrets.k8s_secret_key = Some(v);
}
if let Some(v) = env_get(prefix, "HOST") {
recipe.server.host = Some(v);
}
if let Some(v) = env_get_parsed::<u16>(prefix, "PORT") {
recipe.server.port = Some(v);
}
if let Some(v) = env_get(prefix, "LOG_LEVEL") {
recipe.server.log_level = Some(v);
}
if let Some(v) = env_get(prefix, "LOG_FORMAT") {
match v.to_ascii_lowercase().as_str() {
"json" => recipe.server.log_format = Some(LogFormatStr::Json),
"text" => recipe.server.log_format = Some(LogFormatStr::Text),
_ => {}
}
}
if let Some(v) = env_get(prefix, "STORE_DATA_DIR") {
recipe.server.data_dir = Some(v.into());
}
}
fn env_get(prefix: &str, name: &str) -> Option<String> {
std::env::var(format!("{prefix}_{name}"))
.ok()
.filter(|v| !v.is_empty())
}
fn env_get_parsed<T: std::str::FromStr>(prefix: &str, name: &str) -> Option<T> {
env_get(prefix, name).and_then(|v| v.parse().ok())
}
pub fn peek_service(path: &Path) -> Result<ServiceKind, RecipeError> {
let raw = std::fs::read_to_string(path).map_err(|source| RecipeError::Io {
path: path.to_path_buf(),
source,
})?;
#[derive(serde::Deserialize)]
struct OnlyDeployment {
deployment: super::schema::DeploymentSection,
}
let only: OnlyDeployment = toml::from_str(&raw)?;
Ok(only.deployment.service)
}
pub fn require_service(recipe: &SetupRecipe, expected: ServiceKind) -> Result<(), RecipeError> {
let actual = recipe.deployment.service;
if actual != expected {
return Err(RecipeError::InvalidField {
service: actual,
field: "deployment.service",
reason: format!(
"recipe targets {actual} but this binary is {expected} — \
re-run with the matching binary or update the recipe"
),
});
}
Ok(())
}
pub fn to_log_format(s: LogFormatStr) -> crate::server::config::LogFormat {
match s {
LogFormatStr::Text => crate::server::config::LogFormat::Text,
LogFormatStr::Json => crate::server::config::LogFormat::Json,
}
}
pub fn resolve_secrets_config(
recipe: &SetupRecipe,
default_secret_name: &str,
default_keyring_service: &str,
) -> crate::server::config::SecretsConfig {
let mut out = crate::server::config::SecretsConfig::default();
let backend = recipe.secrets.backend.unwrap_or(SecretsBackend::Keyring);
out.backend = Some(backend.to_vti());
match backend {
SecretsBackend::Keyring => {
out.keyring_service = recipe
.secrets
.keyring_service
.clone()
.unwrap_or_else(|| default_keyring_service.to_string());
}
SecretsBackend::Aws => {
out.aws_region = recipe.secrets.aws_region.clone();
out.aws_secret_name = Some(
recipe
.secrets
.aws_secret_name
.clone()
.unwrap_or_else(|| default_secret_name.to_string()),
);
out.keyring_service = default_keyring_service.to_string();
}
SecretsBackend::Gcp => {
out.gcp_project = recipe.secrets.gcp_project.clone();
out.gcp_secret_name = Some(
recipe
.secrets
.gcp_secret_name
.clone()
.unwrap_or_else(|| default_secret_name.to_string()),
);
out.keyring_service = default_keyring_service.to_string();
}
SecretsBackend::Azure => {
out.azure_vault_url = recipe.secrets.azure_vault_url.clone();
out.azure_secret_name = Some(
recipe
.secrets
.azure_secret_name
.clone()
.unwrap_or_else(|| default_secret_name.to_string()),
);
out.keyring_service = default_keyring_service.to_string();
}
SecretsBackend::Vault => {
out.vault_addr = recipe.secrets.vault_addr.clone();
out.vault_secret_path = Some(
recipe
.secrets
.vault_secret_path
.clone()
.unwrap_or_else(|| default_secret_name.to_string()),
);
if let Some(v) = recipe.secrets.vault_kv_mount.clone() {
out.vault_kv_mount = v;
}
if let Some(v) = recipe.secrets.vault_secret_key.clone() {
out.vault_secret_key = v;
}
out.vault_namespace = recipe.secrets.vault_namespace.clone();
if let Some(v) = recipe.secrets.vault_auth_method.clone() {
out.vault_auth_method = v;
}
out.vault_k8s_role = recipe.secrets.vault_k8s_role.clone();
if let Some(v) = recipe.secrets.vault_k8s_mount.clone() {
out.vault_k8s_mount = v;
}
if let Some(v) = recipe.secrets.vault_k8s_jwt_path.clone() {
out.vault_k8s_jwt_path = v;
}
out.vault_token = recipe.secrets.vault_token.clone();
out.vault_approle_role_id = recipe.secrets.vault_approle_role_id.clone();
out.vault_approle_secret_id = recipe.secrets.vault_approle_secret_id.clone();
if let Some(v) = recipe.secrets.vault_approle_mount.clone() {
out.vault_approle_mount = v;
}
if let Some(v) = recipe.secrets.vault_skip_verify {
out.vault_skip_verify = v;
}
out.keyring_service = default_keyring_service.to_string();
}
SecretsBackend::K8s => {
out.k8s_secret_name = Some(
recipe
.secrets
.k8s_secret_name
.clone()
.unwrap_or_else(|| default_secret_name.to_string()),
);
out.k8s_namespace = recipe.secrets.k8s_namespace.clone();
if let Some(v) = recipe.secrets.k8s_secret_key.clone() {
out.k8s_secret_key = v;
}
out.keyring_service = default_keyring_service.to_string();
}
SecretsBackend::Plaintext => {
out.confirm_plaintext = recipe.secrets.confirm_plaintext;
out.keyring_service = default_keyring_service.to_string();
}
}
out
}
pub fn active_backend(recipe: &SetupRecipe) -> SecretsBackend {
recipe.secrets.backend.unwrap_or(SecretsBackend::Keyring)
}
pub fn vta_mode_str(mode: VtaMode) -> &'static str {
match mode {
VtaMode::Online => "online",
VtaMode::OfflinePrepare => "offline-prepare",
VtaMode::OfflineComplete => "offline-complete",
VtaMode::SelfManaged => "self-managed",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::setup_recipe::schema::{
DeploymentSection, IdentitySection, OutputSection, SecretsSection, SetupRecipe, VtaSection,
};
use std::path::PathBuf;
fn fixture(service: ServiceKind) -> SetupRecipe {
SetupRecipe {
deployment: DeploymentSection {
service,
vta_mode: VtaMode::Online,
},
output: OutputSection {
config_path: PathBuf::from("config.toml"),
},
server: Default::default(),
identity: IdentitySection {
public_url: Some("https://example.com".into()),
did_hosting_url: Some("https://example.com".into()),
..Default::default()
},
vta: VtaSection {
did: Some("did:webvh:vta.example.com".into()),
..Default::default()
},
secrets: SecretsSection::default(),
admin: Default::default(),
reprovision: Default::default(),
watcher: Default::default(),
daemon: Default::default(),
}
}
#[test]
fn env_overrides_apply_for_daemon_prefix() {
let key = "DAEMON_PUBLIC_URL";
let prior = std::env::var(key).ok();
unsafe { std::env::set_var(key, "https://from-env.example") };
let mut r = fixture(ServiceKind::Daemon);
apply_env_overrides(&mut r);
assert_eq!(
r.identity.public_url.as_deref(),
Some("https://from-env.example")
);
match prior {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn resolve_secrets_keyring_default_uses_default_service_name() {
let r = fixture(ServiceKind::Server);
let cfg = resolve_secrets_config(&r, "test-secrets", "webvh");
assert_eq!(cfg.keyring_service, "webvh");
}
#[test]
fn resolve_secrets_vault_maps_fields_and_defaults_path() {
let mut r = fixture(ServiceKind::Server);
r.secrets.backend = Some(SecretsBackend::Vault);
r.secrets.vault_addr = Some("https://vault.example.com:8200".into());
r.secrets.vault_k8s_role = Some("did-hosting-server".into());
let cfg = resolve_secrets_config(&r, "webvh-server-secrets", "webvh");
assert_eq!(
cfg.vault_addr.as_deref(),
Some("https://vault.example.com:8200")
);
assert_eq!(
cfg.vault_secret_path.as_deref(),
Some("webvh-server-secrets")
);
assert_eq!(cfg.vault_k8s_role.as_deref(), Some("did-hosting-server"));
assert_eq!(cfg.vault_kv_mount, "secret");
assert_eq!(cfg.vault_auth_method, "kubernetes");
}
#[test]
fn resolve_secrets_k8s_defaults_secret_name() {
let mut r = fixture(ServiceKind::Server);
r.secrets.backend = Some(SecretsBackend::K8s);
r.secrets.k8s_namespace = Some("webvh".into());
let cfg = resolve_secrets_config(&r, "webvh-server-secrets", "webvh");
assert_eq!(cfg.k8s_secret_name.as_deref(), Some("webvh-server-secrets"));
assert_eq!(cfg.k8s_namespace.as_deref(), Some("webvh"));
assert_eq!(cfg.k8s_secret_key, "seed");
}
#[test]
fn vault_backend_requires_addr() {
let mut r = fixture(ServiceKind::Server);
r.secrets.backend = Some(SecretsBackend::Vault);
let err = r.validate().expect_err("vault without addr must fail");
assert!(
matches!(err, RecipeError::MissingField { field, .. } if field.contains("vault_addr")),
"unexpected error: {err:?}"
);
}
#[test]
fn require_service_rejects_mismatch() {
let r = fixture(ServiceKind::Daemon);
assert!(require_service(&r, ServiceKind::Server).is_err());
require_service(&r, ServiceKind::Daemon).unwrap();
}
}