use std::{
collections::{BTreeMap, BTreeSet},
env, fmt,
fs::File,
io::{Read, Seek, SeekFrom},
path::{Path, PathBuf},
};
use greentic_secrets_lib::{
DevStore, GeneratedSecretRequirement, GeneratedSecretScope, SecretsStore, TEAM_PLACEHOLDER,
canonical_secret_name, canonical_secret_store_key, generated_scope_team, normalize_team,
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use zip::{ZipArchive, result::ZipError};
use crate::config::{DeployerConfig, Provider};
use crate::contract::DeployerCapability;
use crate::environment::{EnvironmentStore, LocalFsStore};
use crate::error::{DeployerError, Result};
use greentic_deploy_spec::{EnvId, Environment};
const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
const SECRET_ASSET_PATHS: &[&str] = &[
"assets/secret-requirements.json",
"assets/secret_requirements.json",
"secret-requirements.json",
"secret_requirements.json",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeSecretRequirement {
pub uri: String,
pub provider_id: String,
pub key: String,
pub required: bool,
pub default_value: Option<String>,
pub aliases: Vec<String>,
pub generated: Option<GeneratedSecretRequirement>,
pub source: PathBuf,
}
#[derive(Clone, PartialEq, Eq)]
pub struct SecretValue(String);
impl SecretValue {
pub fn expose(&self) -> &str {
&self.0
}
}
impl From<String> for SecretValue {
fn from(value: String) -> Self {
Self(value)
}
}
impl fmt::Debug for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedRuntimeSecret {
pub requirement: RuntimeSecretRequirement,
pub value: SecretValue,
pub source: SecretValueSource,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SecretValueSource {
Env { key: String },
DevStore { path: PathBuf },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MissingRuntimeSecret {
pub requirement: RuntimeSecretRequirement,
pub checked_sources: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeSecretResolution {
pub resolved: Vec<ResolvedRuntimeSecret>,
pub missing: Vec<MissingRuntimeSecret>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PromotedRuntimeSecret {
pub uri: String,
pub remote_name: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PromoteRuntimeSecretsReport {
pub promoted: Vec<PromotedRuntimeSecret>,
pub skipped: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct RuntimeSecretContext {
pub bundle_root: PathBuf,
pub pack_paths: Vec<PathBuf>,
pub environment: String,
pub tenant: String,
pub team: Option<String>,
pub extra_dev_store_roots: Vec<PathBuf>,
}
struct CloudSecretInputs {
ctx: RuntimeSecretContext,
pack_requirements: Vec<RuntimeSecretRequirement>,
endpoint_requirements: Vec<RuntimeSecretRequirement>,
}
fn collect_cloud_secret_inputs(config: &DeployerConfig) -> Result<Option<CloudSecretInputs>> {
let Some(bundle_root) = config
.bundle_root
.clone()
.or_else(|| infer_bundle_root_from_pack_path(&config.pack_path))
else {
return Ok(None);
};
let mut pack_paths = vec![config.pack_path.clone()];
if let Some(provider_pack) = config.provider_pack.as_ref() {
pack_paths.push(provider_pack.clone());
}
pack_paths.extend(discover_bundle_pack_paths(&bundle_root)?);
pack_paths = dedup_paths(pack_paths);
let loaded_env = load_environment_for_config(config)?;
let extra_dev_store_roots = loaded_env
.as_ref()
.map(|(_, env_dir)| vec![env_dir.clone()])
.unwrap_or_default();
let ctx = RuntimeSecretContext {
bundle_root,
pack_paths,
environment: config.environment.clone(),
tenant: config.tenant.clone(),
team: None,
extra_dev_store_roots,
};
let pack_requirements = collect_requirements(&ctx)?;
let mut endpoint_requirements = match loaded_env.as_ref() {
Some((env, _)) => collect_endpoint_webhook_requirements(env)?,
None => Vec::new(),
};
if !endpoint_requirements.is_empty() {
let pack_uris: BTreeSet<&str> = pack_requirements.iter().map(|r| r.uri.as_str()).collect();
endpoint_requirements.retain(|r| !pack_uris.contains(r.uri.as_str()));
}
Ok(Some(CloudSecretInputs {
ctx,
pack_requirements,
endpoint_requirements,
}))
}
pub async fn resolve_for_cloud_apply(
config: &DeployerConfig,
) -> Result<Option<RuntimeSecretResolution>> {
if !matches!(
config.provider,
Provider::Aws | Provider::Azure | Provider::Gcp
) || config.capability != DeployerCapability::Apply
|| !config.execute_local
{
return Ok(None);
}
let Some(CloudSecretInputs {
ctx,
mut pack_requirements,
endpoint_requirements,
}) = collect_cloud_secret_inputs(config)?
else {
return Ok(None);
};
pack_requirements.extend(endpoint_requirements);
if pack_requirements.is_empty() {
return Ok(None);
}
let resolution = resolve_runtime_secrets(&ctx, &pack_requirements).await;
if !resolution.missing.is_empty() {
return Err(DeployerError::Config(format_missing_runtime_secrets(
&resolution.missing,
)));
}
Ok(Some(resolution))
}
fn load_environment_for_config(config: &DeployerConfig) -> Result<Option<(Environment, PathBuf)>> {
let Ok(env_id) = EnvId::try_from(config.environment.as_str()) else {
return Ok(None);
};
let Some(root) = LocalFsStore::default_root() else {
return Ok(None);
};
load_environment_from_store(&LocalFsStore::new(root), &env_id)
}
fn load_environment_from_store(
store: &LocalFsStore,
env_id: &EnvId,
) -> Result<Option<(Environment, PathBuf)>> {
if !store
.exists(env_id)
.map_err(|e| DeployerError::Config(format!("environment store: {e}")))?
{
return Ok(None);
}
let env = store
.load(env_id)
.map_err(|e| DeployerError::Config(format!("load environment {env_id}: {e}")))?;
let env_dir = store
.env_dir(env_id)
.map_err(|e| DeployerError::Config(format!("environment dir {env_id}: {e}")))?;
Ok(Some((env, env_dir)))
}
const WEBHOOK_SECRET_KEY: &str = "webhook_secret";
fn collect_endpoint_webhook_requirements(
env: &Environment,
) -> Result<Vec<RuntimeSecretRequirement>> {
let mut out = Vec::new();
for endpoint in &env.messaging_endpoints {
let Some(secret_ref) = endpoint.webhook_secret_ref.as_ref() else {
continue;
};
let uri = secret_ref
.to_store_uri()
.map_err(|e| {
DeployerError::Config(format!(
"messaging endpoint {} webhook_secret_ref: {e}",
endpoint.endpoint_id
))
})?
.to_string();
let eid_lower = endpoint.endpoint_id.to_string().to_lowercase();
out.push(RuntimeSecretRequirement {
uri,
provider_id: format!("messaging-{eid_lower}"),
key: WEBHOOK_SECRET_KEY.to_string(),
required: true,
default_value: None,
aliases: Vec::new(),
generated: None,
source: PathBuf::from("environment.json"),
});
}
Ok(out)
}
fn cloud_remote_name(
provider: Provider,
prefix: &str,
requirement: &RuntimeSecretRequirement,
) -> Option<String> {
match provider {
Provider::Aws => Some(cloud_secret_name(
prefix,
&requirement.provider_id,
&requirement.key,
)),
Provider::Azure => Some(flat_cloud_secret_name(
prefix,
&requirement.provider_id,
&requirement.key,
127,
)),
Provider::Gcp => Some(flat_cloud_secret_name(
prefix,
&requirement.provider_id,
&requirement.key,
255,
)),
_ => None,
}
}
fn build_cloud_env_map(
provider: Provider,
prefix: &str,
pack_requirements: &[RuntimeSecretRequirement],
endpoint_requirements: &[RuntimeSecretRequirement],
resolved_uris: &BTreeSet<String>,
) -> BTreeMap<String, String> {
let mut env_map = BTreeMap::new();
for requirement in pack_requirements {
if !requirement.required && !resolved_uris.contains(&requirement.uri) {
continue;
}
let Some(remote_name) = cloud_remote_name(provider, prefix, requirement) else {
continue;
};
env_map.insert(requirement.uri.clone(), remote_name.clone());
env_map.insert(requirement.key.clone(), remote_name);
}
for requirement in endpoint_requirements {
if env_map.contains_key(&requirement.uri) {
continue;
}
if !requirement.required && !resolved_uris.contains(&requirement.uri) {
continue;
}
let Some(remote_name) = cloud_remote_name(provider, prefix, requirement) else {
continue;
};
env_map.insert(requirement.uri.clone(), remote_name);
}
env_map
}
pub fn default_cloud_secret_prefix(environment: &str, tenant: &str, team: Option<&str>) -> String {
let team = normalize_team(team);
format!(
"greentic/{environment}/{tenant}/{}",
team.as_deref().unwrap_or(TEAM_PLACEHOLDER)
)
}
pub fn collect_requirements(ctx: &RuntimeSecretContext) -> Result<Vec<RuntimeSecretRequirement>> {
let mut by_uri = BTreeMap::new();
for pack_path in &ctx.pack_paths {
if !pack_path.exists() {
continue;
}
let provider_id = provider_id_from_pack_path(pack_path);
for req in load_secret_requirements_from_pack(pack_path)? {
let key = canonical_secret_name(&req.key);
let generated = req
.generated
.as_ref()
.map(AssetGeneratedSecret::to_requirement);
let team = match &generated {
Some(generated) => generated_scope_team(generated, ctx.team.as_deref()),
None => ctx.team.as_deref(),
};
let uri = canonical_secret_uri(&ctx.environment, &ctx.tenant, team, &provider_id, &key);
let aliases = req
.aliases
.iter()
.map(|alias| canonical_secret_name(alias))
.collect();
by_uri
.entry(uri.clone())
.or_insert(RuntimeSecretRequirement {
uri,
provider_id: provider_id.clone(),
key,
required: req.required,
default_value: req.default_value,
aliases,
generated,
source: pack_path.clone(),
});
}
}
Ok(by_uri.into_values().collect())
}
pub fn bundle_secret_requirements(
bundle_root: &Path,
environment: &str,
tenant: &str,
) -> Result<Vec<RuntimeSecretRequirement>> {
let pack_paths = discover_bundle_pack_paths(bundle_root)?;
let ctx = RuntimeSecretContext {
bundle_root: bundle_root.to_path_buf(),
pack_paths,
environment: environment.to_string(),
tenant: tenant.to_string(),
team: None,
extra_dev_store_roots: Vec::new(),
};
collect_requirements(&ctx)
}
pub fn manifest_secret_path(uri: &str, environment: &str) -> Option<String> {
uri.strip_prefix(&format!("secrets://{environment}/"))
.map(str::to_string)
}
pub fn runtime_secret_env_map_for_cloud(
config: &DeployerConfig,
) -> Result<BTreeMap<String, String>> {
if !matches!(
config.provider,
Provider::Aws | Provider::Azure | Provider::Gcp
) {
return Ok(BTreeMap::new());
}
let Some(CloudSecretInputs {
ctx,
pack_requirements,
endpoint_requirements,
}) = collect_cloud_secret_inputs(config)?
else {
return Ok(BTreeMap::new());
};
let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
let all_requirements: Vec<RuntimeSecretRequirement> = pack_requirements
.iter()
.chain(endpoint_requirements.iter())
.cloned()
.collect();
let resolution = block_on_async_resolution(&ctx, &all_requirements);
let resolved_uris: BTreeSet<String> = resolution
.resolved
.into_iter()
.map(|r| r.requirement.uri)
.collect();
let env_map = build_cloud_env_map(
config.provider,
&prefix,
&pack_requirements,
&endpoint_requirements,
&resolved_uris,
);
Ok(env_map)
}
fn block_on_async_resolution(
ctx: &RuntimeSecretContext,
requirements: &[RuntimeSecretRequirement],
) -> RuntimeSecretResolution {
std::thread::scope(|scope| {
scope
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build runtime for runtime-secret resolution")
.block_on(resolve_runtime_secrets(ctx, requirements))
})
.join()
.expect("runtime-secret resolution thread panicked")
})
}
pub async fn resolve_runtime_secrets(
ctx: &RuntimeSecretContext,
requirements: &[RuntimeSecretRequirement],
) -> RuntimeSecretResolution {
let store_paths = dev_store_paths(&ctx.bundle_root, &ctx.extra_dev_store_roots);
let mut resolved = Vec::new();
let mut missing = Vec::new();
for requirement in requirements {
let mut checked_sources = Vec::new();
let team = match &requirement.generated {
Some(generated) => generated_scope_team(generated, ctx.team.as_deref()),
None => ctx.team.as_deref(),
};
let candidate_uris: Vec<String> = std::iter::once(requirement.uri.clone())
.chain(requirement.aliases.iter().map(|alias| {
canonical_secret_uri(
&ctx.environment,
&ctx.tenant,
team,
&requirement.provider_id,
alias,
)
}))
.collect();
let mut found = None;
'candidates: for uri in &candidate_uris {
if let Some(env_key) = canonical_secret_store_key(uri) {
checked_sources.push(format!("env {env_key}"));
if let Ok(value) = env::var(&env_key)
&& !value.is_empty()
{
if is_placeholder_secret_value(&value, uri) {
checked_sources
.push(format!("env {env_key} (auto-seeded placeholder, ignored)"));
} else {
found = Some((SecretValueSource::Env { key: env_key }, value));
break 'candidates;
}
}
}
for path in &store_paths {
checked_sources.push(path.display().to_string());
if !path.exists() {
continue;
}
if let Ok(store) = DevStore::with_path(path)
&& let Ok(bytes) = store.get(uri).await
&& let Ok(value) = String::from_utf8(bytes)
&& !value.is_empty()
{
if let Some(env_key) = extract_env_placeholder(&value) {
checked_sources.push(format!("env ${{{env_key}}} (from dev store)"));
match env::var(&env_key) {
Ok(expanded)
if !expanded.is_empty()
&& !is_placeholder_secret_value(&expanded, uri) =>
{
found = Some((
SecretValueSource::DevStore { path: path.clone() },
expanded,
));
break 'candidates;
}
_ => continue,
}
}
if is_placeholder_secret_value(&value, uri) {
checked_sources.push(format!(
"{} (auto-seeded placeholder, ignored)",
path.display()
));
continue;
}
found = Some((SecretValueSource::DevStore { path: path.clone() }, value));
break 'candidates;
}
}
}
if let Some((source, value)) = found {
resolved.push(ResolvedRuntimeSecret {
requirement: requirement.clone(),
value: SecretValue(value),
source,
});
} else if requirement.required {
missing.push(MissingRuntimeSecret {
requirement: requirement.clone(),
checked_sources,
});
}
}
RuntimeSecretResolution { resolved, missing }
}
pub fn format_missing_runtime_secrets(missing: &[MissingRuntimeSecret]) -> String {
let mut out = String::from("missing required runtime secrets:\n");
for entry in missing {
out.push_str(&format!(" - {}\n", entry.requirement.uri));
if entry.requirement.generated.is_some() {
out.push_str(
" (system-generated — run `gtc setup` / provisioning to mint it before deploy)\n",
);
}
out.push_str(" checked:\n");
for source in &entry.checked_sources {
out.push_str(&format!(" - {source}\n"));
}
}
out
}
pub fn cloud_secret_name(prefix: &str, provider_id: &str, key: &str) -> String {
format!(
"{}/{}/{}",
prefix.trim_matches('/'),
canonical_secret_name(provider_id),
canonical_secret_name(key)
)
}
pub fn flat_cloud_secret_name(
prefix: &str,
provider_id: &str,
key: &str,
max_len: usize,
) -> String {
let raw = format!("{}-{}-{}", prefix.trim_matches('/'), provider_id, key);
let mut normalized = String::with_capacity(raw.len());
let mut prev_dash = false;
for ch in raw.chars() {
let next = match ch {
'A'..='Z' => ch.to_ascii_lowercase(),
'a'..='z' | '0'..='9' => ch,
'-' => '-',
'_' | '/' | '.' | ' ' => '-',
_ => continue,
};
if next == '-' {
if prev_dash {
continue;
}
prev_dash = true;
} else {
prev_dash = false;
}
normalized.push(next);
}
let normalized = normalized.trim_matches('-');
if normalized.len() <= max_len {
return normalized.to_string();
}
let mut hasher = Sha256::new();
hasher.update(normalized.as_bytes());
let digest = hex::encode(hasher.finalize());
let suffix = format!("-{}", &digest[..12]);
let keep = max_len.saturating_sub(suffix.len());
format!("{}{}", normalized[..keep].trim_matches('-'), suffix)
}
pub fn canonical_secret_uri(
env: &str,
tenant: &str,
team: Option<&str>,
provider: &str,
key: &str,
) -> String {
let team = normalize_team(team);
format!(
"secrets://{}/{}/{}/{}/{}",
env,
tenant,
team.as_deref().unwrap_or(TEAM_PLACEHOLDER),
provider,
canonical_secret_name(key)
)
}
fn dev_store_paths(bundle_root: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(path) = env::var_os(DEV_SECRETS_PATH_ENV) {
paths.push(PathBuf::from(path));
}
for root in std::iter::once(bundle_root).chain(extra_roots.iter().map(PathBuf::as_path)) {
paths.push(root.join(".greentic/dev/.dev.secrets.env"));
paths.push(root.join(".greentic/state/dev/.dev.secrets.env"));
}
let mut seen = BTreeSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}
fn discover_bundle_pack_paths(bundle_root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_pack_paths_from_dir(&bundle_root.join("packs"), &mut out)?;
collect_pack_paths_from_dir(&bundle_root.join("providers"), &mut out)?;
out.sort();
Ok(out)
}
fn collect_pack_paths_from_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) == Some("gtpack") {
out.push(path);
continue;
}
if path.is_dir() {
if path.join("pack.yaml").exists() || path.join("manifest.cbor").exists() {
out.push(path);
} else {
collect_pack_paths_from_dir(&path, out)?;
}
}
}
Ok(())
}
fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = BTreeSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}
fn provider_id_from_pack_path(pack_path: &Path) -> String {
pack_path
.file_stem()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "provider".to_string())
}
fn infer_bundle_root_from_pack_path(pack_path: &Path) -> Option<PathBuf> {
let mut current = if pack_path.is_dir() {
Some(pack_path)
} else {
pack_path.parent()
};
while let Some(path) = current {
if path.file_name().and_then(|value| value.to_str()) == Some("packs") {
return path.parent().map(Path::to_path_buf);
}
if path.join("bundle.yaml").exists() {
return Some(path.to_path_buf());
}
current = path.parent();
}
None
}
fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
if pack_path.is_dir() {
return load_secret_requirements_from_dir(pack_path);
}
if !is_probably_zip(pack_path)? {
return load_secret_requirements_from_tar(pack_path);
}
load_secret_requirements_from_zip(pack_path)
}
fn is_probably_zip(path: &Path) -> Result<bool> {
let mut file = File::open(path)?;
let mut magic = [0_u8; 4];
let read = file.read(&mut magic)?;
Ok(read == magic.len() && magic == [0x50, 0x4b, 0x03, 0x04])
}
fn is_probably_tar(path: &Path) -> Result<bool> {
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(257))?;
let mut magic = [0_u8; 5];
let read = file.read(&mut magic)?;
Ok(read == magic.len() && magic == *b"ustar")
}
fn load_secret_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
let mut requirements = Vec::new();
for asset in SECRET_ASSET_PATHS {
let path = pack_path.join(asset);
if path.exists() {
let contents = std::fs::read_to_string(&path)?;
requirements.extend(parse_requirements(&contents, &path)?);
}
}
let setup_yaml = pack_path.join("assets/setup.yaml");
if setup_yaml.exists() {
let contents = std::fs::read_to_string(&setup_yaml)?;
requirements.extend(parse_setup_secret_requirements(&contents, &setup_yaml)?);
}
Ok(dedup_requirements(requirements))
}
fn load_secret_requirements_from_zip(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
let file = File::open(pack_path)?;
let mut archive = match ZipArchive::new(file) {
Ok(archive) => archive,
Err(_) => return Ok(Vec::new()),
};
let mut requirements = Vec::new();
for asset in SECRET_ASSET_PATHS {
match archive.by_name(asset) {
Ok(mut entry) => {
let mut contents = String::new();
entry.read_to_string(&mut contents)?;
requirements.extend(parse_requirements(&contents, Path::new(asset))?);
}
Err(ZipError::FileNotFound) => continue,
Err(err) => return Err(DeployerError::Other(err.to_string())),
}
}
if let Ok(mut entry) = archive.by_name("assets/setup.yaml") {
let mut contents = String::new();
entry.read_to_string(&mut contents)?;
requirements.extend(parse_setup_secret_requirements(
&contents,
Path::new("assets/setup.yaml"),
)?);
}
Ok(dedup_requirements(requirements))
}
fn load_secret_requirements_from_tar(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
if !is_probably_tar(pack_path)? {
return Ok(Vec::new());
}
let file = File::open(pack_path)?;
let mut archive = tar::Archive::new(file);
let entries = match archive.entries() {
Ok(entries) => entries,
Err(_) => return Ok(Vec::new()),
};
let mut requirements = Vec::new();
for entry in entries {
let mut entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let path = match entry.path() {
Ok(path) => path.into_owned(),
Err(_) => continue,
};
let Some(path_str) = path.to_str() else {
continue;
};
if SECRET_ASSET_PATHS.contains(&path_str) {
let mut contents = String::new();
entry.read_to_string(&mut contents)?;
requirements.extend(parse_requirements(&contents, &path)?);
} else if path_str == "assets/setup.yaml" {
let mut contents = String::new();
entry.read_to_string(&mut contents)?;
requirements.extend(parse_setup_secret_requirements(&contents, &path)?);
}
}
Ok(dedup_requirements(requirements))
}
fn parse_requirements(contents: &str, path: &Path) -> Result<Vec<PackSecretRequirement>> {
serde_json::from_str(contents).map_err(|err| {
DeployerError::Config(format!(
"parse secret requirements from {}: {err}",
path.display()
))
})
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
struct PackSecretRequirement {
key: String,
#[serde(default)]
aliases: Vec<String>,
#[serde(default = "default_required")]
required: bool,
#[serde(default)]
default_value: Option<String>,
#[serde(default)]
generated: Option<AssetGeneratedSecret>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
struct AssetGeneratedSecret {
policy: Option<String>,
length: Option<usize>,
encoding: Option<String>,
scope: Option<AssetGeneratedSecretScope>,
#[serde(default)]
regenerate_if_present: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
struct AssetGeneratedSecretScope {
level: Option<String>,
team: Option<String>,
}
impl AssetGeneratedSecret {
fn to_requirement(&self) -> GeneratedSecretRequirement {
GeneratedSecretRequirement {
policy: self.policy.clone().unwrap_or_else(|| "random".to_string()),
length: self.length.unwrap_or(32),
encoding: self
.encoding
.clone()
.unwrap_or_else(|| "base64url".to_string()),
scope: GeneratedSecretScope {
level: self
.scope
.as_ref()
.and_then(|scope| scope.level.clone())
.unwrap_or_else(|| "team".to_string()),
team: self.scope.as_ref().and_then(|scope| scope.team.clone()),
},
regenerate_if_present: self.regenerate_if_present.unwrap_or(false),
}
}
}
#[derive(Debug, Deserialize)]
struct SetupSpec {
#[serde(default)]
questions: Vec<SetupQuestion>,
}
#[derive(Debug, Deserialize)]
struct SetupQuestion {
name: String,
#[serde(default)]
secret_key: Option<String>,
#[serde(default)]
default: Option<String>,
#[serde(default)]
secret: bool,
#[serde(default)]
required: bool,
}
fn parse_setup_secret_requirements(
contents: &str,
path: &Path,
) -> Result<Vec<PackSecretRequirement>> {
let setup: SetupSpec = serde_yaml_bw::from_str(contents).map_err(|err| {
DeployerError::Config(format!(
"parse setup secrets from {}: {err}",
path.display()
))
})?;
Ok(setup
.questions
.into_iter()
.filter(|question| question.secret)
.map(|question| PackSecretRequirement {
key: question.secret_key.unwrap_or(question.name),
aliases: Vec::new(),
required: question.required,
default_value: question.default,
generated: None,
})
.collect())
}
fn dedup_requirements(requirements: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
let mut by_key = BTreeMap::new();
for requirement in requirements {
let key = canonical_secret_name(&requirement.key);
by_key
.entry(key)
.and_modify(|existing: &mut PackSecretRequirement| {
existing.required |= requirement.required;
if existing.default_value.is_none() {
existing.default_value = requirement.default_value.clone();
}
if existing.aliases.is_empty() {
existing.aliases = requirement.aliases.clone();
}
if existing.generated.is_none() {
existing.generated = requirement.generated.clone();
}
})
.or_insert(requirement);
}
by_key.into_values().collect()
}
fn extract_env_placeholder(value: &str) -> Option<String> {
let trimmed = value.trim();
let inner = trimmed.strip_prefix("${")?.strip_suffix('}')?;
if inner.is_empty() || inner.contains(|c: char| c.is_whitespace() || c == '$' || c == '{') {
return None;
}
Some(inner.to_string())
}
fn is_placeholder_secret_value(value: &str, uri: &str) -> bool {
let trimmed = value.trim();
trimmed == "ollama-placeholder"
|| trimmed
.strip_prefix("placeholder for ")
.is_some_and(|rest| rest == uri)
}
fn default_required() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_deploy_spec::MessagingEndpointId;
#[test]
fn extract_env_placeholder_matches_whole_string_dollar_brace_form() {
assert_eq!(
extract_env_placeholder("${REDIS_URL}").as_deref(),
Some("REDIS_URL")
);
assert_eq!(
extract_env_placeholder("${OPENAI_API_KEY}").as_deref(),
Some("OPENAI_API_KEY")
);
assert_eq!(
extract_env_placeholder(" ${PUBLIC_BASE_URL} ").as_deref(),
Some("PUBLIC_BASE_URL"),
"surrounding whitespace is allowed"
);
}
#[test]
fn extract_env_placeholder_rejects_partial_or_malformed_patterns() {
assert_eq!(extract_env_placeholder("redis://host:6379/0"), None);
assert_eq!(extract_env_placeholder("prefix-${VAR}"), None);
assert_eq!(extract_env_placeholder("${VAR}-suffix"), None);
assert_eq!(extract_env_placeholder("${}"), None);
assert_eq!(extract_env_placeholder("${VAR WITH SPACE}"), None);
assert_eq!(extract_env_placeholder("${NESTED${INNER}}"), None);
}
#[test]
fn canonical_env_key_matches_start_runtime_shape() {
assert_eq!(
canonical_secret_store_key("secrets://dev/demo/_/openai/api_key").as_deref(),
Some("GREENTIC_SECRET__DEV__DEMO_____OPENAI__API_KEY")
);
}
#[test]
fn cloud_secret_name_is_stable_and_normalized() {
assert_eq!(
cloud_secret_name(
"greentic/dev/demo/_",
"messaging-telegram",
"TELEGRAM_BOT_TOKEN"
),
"greentic/dev/demo/_/messaging_telegram/telegram_bot_token"
);
}
#[test]
fn requirement_uri_preserves_pack_provider_id_hyphens() {
let dir = tempfile::tempdir().unwrap();
let pack_dir = dir.path().join("packs/messaging-webchat-gui/assets");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(
pack_dir.join("secret-requirements.json"),
r#"[{"key":"jwt_signing_key","required":true}]"#,
)
.unwrap();
let ctx = RuntimeSecretContext {
bundle_root: dir.path().to_path_buf(),
pack_paths: vec![dir.path().join("packs/messaging-webchat-gui")],
environment: "dev".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: Vec::new(),
};
let requirements = collect_requirements(&ctx).unwrap();
assert_eq!(requirements.len(), 1);
assert_eq!(requirements[0].provider_id, "messaging-webchat-gui");
assert_eq!(
requirements[0].uri,
"secrets://dev/demo/_/messaging-webchat-gui/jwt_signing_key"
);
assert_eq!(
cloud_secret_name(
"greentic/dev/demo/_",
&requirements[0].provider_id,
&requirements[0].key
),
"greentic/dev/demo/_/messaging_webchat_gui/jwt_signing_key"
);
}
#[test]
fn bundle_secret_requirements_reads_packs_dir_scoped_to_tenant() {
let dir = tempfile::tempdir().unwrap();
let pack_dir = dir.path().join("packs/messaging-telegram");
std::fs::create_dir_all(pack_dir.join("assets")).unwrap();
std::fs::write(pack_dir.join("pack.yaml"), "id: messaging-telegram\n").unwrap();
std::fs::write(
pack_dir.join("assets/secret-requirements.json"),
r#"[{"key":"TELEGRAM_BOT_TOKEN","required":true}]"#,
)
.unwrap();
let reqs = bundle_secret_requirements(dir.path(), "local", "legal").unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(
reqs[0].uri,
"secrets://local/legal/_/messaging-telegram/telegram_bot_token"
);
assert_eq!(
manifest_secret_path(&reqs[0].uri, "local").as_deref(),
Some("legal/_/messaging-telegram/telegram_bot_token")
);
}
#[test]
fn bundle_secret_requirements_empty_when_unbuilt() {
let dir = tempfile::tempdir().unwrap();
let reqs = bundle_secret_requirements(dir.path(), "local", "legal").unwrap();
assert!(reqs.is_empty());
}
#[test]
fn manifest_secret_path_rejects_foreign_env_or_scheme() {
assert_eq!(
manifest_secret_path("secrets://prod/legal/_/p/tok", "local"),
None,
"different env is not stripped"
);
assert_eq!(
manifest_secret_path("https://example.com/x", "local"),
None,
"non-secrets scheme is rejected"
);
}
#[test]
fn flat_secret_name_limits_length_with_digest() {
let name = flat_cloud_secret_name(
"greentic/dev/demo/default",
"very-long-provider-name",
"THIS_IS_A_VERY_LONG_SECRET_NAME",
40,
);
assert!(name.len() <= 40);
assert!(name.starts_with("greentic-dev-demo-default"));
}
#[test]
fn infers_bundle_root_from_pack_path_under_packs_dir() {
let path = Path::new("/tmp/demo-bundle/packs/app.gtpack");
assert_eq!(
infer_bundle_root_from_pack_path(path).as_deref(),
Some(Path::new("/tmp/demo-bundle"))
);
}
#[test]
fn skips_non_zip_gtpack_when_scanning_secret_requirements() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("aws.gtpack");
std::fs::write(&pack, b"not a zip").unwrap();
let reqs = load_secret_requirements_from_pack(&pack).unwrap();
assert!(reqs.is_empty());
}
#[test]
fn reads_secret_requirements_from_tar_gtpack() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("provider.gtpack");
let file = File::create(&pack).unwrap();
let mut builder = tar::Builder::new(file);
let contents = br#"[{"key":"API_TOKEN","required":true}]"#;
let mut header = tar::Header::new_gnu();
header.set_path("assets/secret-requirements.json").unwrap();
header.set_size(contents.len() as u64);
header.set_cksum();
builder
.append(&header, contents.as_slice())
.expect("append tar entry");
builder.finish().unwrap();
let reqs = load_secret_requirements_from_pack(&pack).unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].key, "API_TOKEN");
}
#[test]
fn reads_secret_requirements_from_setup_yaml() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("pack");
std::fs::create_dir_all(pack.join("assets")).unwrap();
std::fs::write(
pack.join("assets/setup.yaml"),
r#"
questions:
- name: api_key
secret: true
required: true
- name: display_name
secret: false
"#,
)
.unwrap();
let reqs = load_secret_requirements_from_pack(&pack).unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].key, "api_key");
assert!(reqs[0].required);
}
#[test]
fn discovers_provider_pack_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("providers/messaging")).unwrap();
std::fs::write(
dir.path()
.join("providers/messaging/messaging-webchat-gui.gtpack"),
b"",
)
.unwrap();
let paths = discover_bundle_pack_paths(dir.path()).unwrap();
assert_eq!(paths.len(), 1);
assert!(paths[0].ends_with("messaging-webchat-gui.gtpack"));
}
#[tokio::test]
async fn resolve_runtime_secrets_no_longer_falls_back_to_setup_answers() {
let dir = tempfile::tempdir().unwrap();
let answers_dir = dir.path().join("state/config/demo-pack");
std::fs::create_dir_all(&answers_dir).unwrap();
std::fs::write(
answers_dir.join("setup-answers.json"),
r#"{"api_key":"STALE-PLAINTEXT-MUST-NOT-LEAK"}"#,
)
.unwrap();
let ctx = RuntimeSecretContext {
bundle_root: dir.path().to_path_buf(),
pack_paths: Vec::new(),
environment: "dev".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: Vec::new(),
};
let requirement = RuntimeSecretRequirement {
uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "api_key"),
provider_id: "demo_pack".into(),
key: "api_key".into(),
required: true,
default_value: None,
aliases: Vec::new(),
generated: None,
source: dir.path().join("packs/demo-pack.gtpack"),
};
let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
assert!(
resolution.resolved.is_empty(),
"no source means no resolution"
);
assert_eq!(resolution.missing.len(), 1);
assert!(
!resolution.missing[0]
.checked_sources
.iter()
.any(|s| s.contains("setup-answers")),
"setup-answers must not appear in checked_sources after B12a",
);
}
fn build_skips_unresolved_optional_fixture(
bundle_root: &Path,
) -> (PathBuf, std::path::PathBuf) {
let packs_dir = bundle_root.join("packs");
let config_dir = bundle_root.join("state/config/demo-app");
std::fs::create_dir_all(packs_dir.join("demo-app/assets")).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
packs_dir.join("demo-app/assets/setup.yaml"),
r#"
questions:
- name: api_key
secret: true
required: false
- name: oauth_client_secret
secret: true
required: false
- name: jwt_signing_key
secret: true
required: true
"#,
)
.unwrap();
std::fs::write(
config_dir.join("setup-answers.json"),
r#"{"api_key":"STALE-PLAINTEXT-MUST-NOT-LEAK"}"#,
)
.unwrap();
(packs_dir.clone(), packs_dir.join("demo-app"))
}
fn deployer_config_for_fixture(bundle_root: &Path, pack_path: PathBuf) -> DeployerConfig {
DeployerConfig {
capability: DeployerCapability::Apply,
provider: Provider::Aws,
strategy: "iac-only".into(),
tenant: "demo".into(),
environment: "dev".into(),
pack_path,
bundle_root: Some(bundle_root.to_path_buf()),
providers_dir: PathBuf::from("providers/deployer"),
packs_dir: PathBuf::from("packs"),
provider_pack: None,
pack_ref: None,
distributor_url: None,
distributor_token: None,
preview: false,
dry_run: false,
execute_local: true,
output: crate::config::OutputFormat::Json,
greentic: greentic_config::ConfigResolver::new()
.load()
.unwrap()
.config,
provenance: greentic_config::ProvenanceMap::new(),
config_warnings: Vec::new(),
deploy_pack_id_override: None,
deploy_flow_id_override: None,
bundle_source: Some("file:///tmp/demo.gtbundle".into()),
bundle_digest: Some(
"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
),
repo_registry_base: None,
store_registry_base: None,
}
}
#[test]
fn runtime_secret_env_map_skips_unresolved_optional_secrets() {
let dir = tempfile::tempdir().unwrap();
let bundle_root = dir.path();
let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
let config = deployer_config_for_fixture(bundle_root, pack_path);
let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
assert!(env_map.contains_key("secrets://dev/demo/_/demo-app/jwt_signing_key"));
assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/api_key"));
assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/oauth_client_secret"));
assert!(!env_map.contains_key("oauth_client_secret"));
}
fn seed_devstore_api_key(bundle_root: &Path) {
use greentic_secrets_lib::{DevStore, SecretFormat};
let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
let store = DevStore::with_path(&store_path).unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
store
.put(
"secrets://dev/demo/_/demo-app/api_key",
SecretFormat::Text,
b"from-dev-store",
)
.await
.unwrap();
});
}
fn assert_devstore_optional_in_env_map(env_map: &BTreeMap<String, String>) {
assert!(
env_map.contains_key("secrets://dev/demo/_/demo-app/api_key"),
"optional secret with DevStore value MUST appear in env_map: {env_map:?}",
);
assert!(env_map.contains_key("secrets://dev/demo/_/demo-app/jwt_signing_key"));
assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/oauth_client_secret"));
}
#[test]
fn runtime_secret_env_map_includes_optional_secrets_with_devstore_value() {
let dir = tempfile::tempdir().unwrap();
let bundle_root = dir.path();
let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
seed_devstore_api_key(bundle_root);
let config = deployer_config_for_fixture(bundle_root, pack_path);
let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
assert_devstore_optional_in_env_map(&env_map);
}
#[test]
fn runtime_secret_env_map_callable_from_within_current_thread_runtime() {
let dir = tempfile::tempdir().unwrap();
let bundle_root = dir.path();
let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
seed_devstore_api_key(bundle_root);
let config = deployer_config_for_fixture(bundle_root, pack_path);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let env_map = rt
.block_on(async { runtime_secret_env_map_for_cloud(&config) })
.unwrap();
assert_devstore_optional_in_env_map(&env_map);
}
#[test]
fn secret_value_debug_is_redacted() {
let value = SecretValue("super-secret".to_string());
assert_eq!(format!("{value:?}"), "<redacted>");
}
#[tokio::test]
async fn resolve_runtime_secrets_honors_alias_uris() {
let dir = tempfile::tempdir().unwrap();
let bundle_root = dir.path();
let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
let alias_uri = canonical_secret_uri("dev", "demo", None, "demo_pack", "legacy_jwt_key");
{
let store = DevStore::with_path(&store_path).unwrap();
store
.put(
&alias_uri,
greentic_secrets_lib::SecretFormat::Text,
b"aliased-value",
)
.await
.unwrap();
}
let ctx = RuntimeSecretContext {
bundle_root: bundle_root.to_path_buf(),
pack_paths: Vec::new(),
environment: "dev".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: Vec::new(),
};
let requirement = RuntimeSecretRequirement {
uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "jwt_signing_key"),
provider_id: "demo_pack".into(),
key: "jwt_signing_key".into(),
required: true,
default_value: None,
aliases: vec!["legacy_jwt_key".into()],
generated: None,
source: bundle_root.join("packs/demo-pack.gtpack"),
};
let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
assert!(resolution.missing.is_empty(), "alias value must resolve");
assert_eq!(resolution.resolved.len(), 1);
assert_eq!(resolution.resolved[0].value.expose(), "aliased-value");
}
#[test]
fn collect_requirements_parses_generated_and_aliases() {
let dir = tempfile::tempdir().unwrap();
let pack_dir = dir.path().join("packs/messaging-webex/assets");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(
pack_dir.join("secret-requirements.json"),
r#"[{"key":"webex_webhook_secret","aliases":["WEBEX_WEBHOOK_SECRET"],"required":true,"generated":{"policy":"random","length":20,"encoding":"raw_text","scope":{"level":"tenant","team":"_"}}}]"#,
)
.unwrap();
let ctx = RuntimeSecretContext {
bundle_root: dir.path().to_path_buf(),
pack_paths: vec![dir.path().join("packs/messaging-webex")],
environment: "dev".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: Vec::new(),
};
let reqs = collect_requirements(&ctx).unwrap();
assert_eq!(reqs.len(), 1);
let req = &reqs[0];
assert_eq!(req.key, "webex_webhook_secret");
assert_eq!(req.aliases, vec!["webex_webhook_secret".to_string()]);
let generated = req.generated.as_ref().expect("generated policy parsed");
assert_eq!(generated.policy, "random");
assert_eq!(generated.length, 20);
assert_eq!(generated.encoding, "raw_text");
assert_eq!(generated.scope.level, "tenant");
assert_eq!(generated.scope.team.as_deref(), Some("_"));
}
#[tokio::test]
async fn generated_secret_resolves_from_its_declared_team_scope() {
let dir = tempfile::tempdir().unwrap();
let bundle_root = dir.path();
let pack_dir = bundle_root.join("packs/messaging-webex/assets");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(
pack_dir.join("secret-requirements.json"),
r#"[{"key":"webex_webhook_secret","aliases":["legacy_webhook"],"required":true,"generated":{"policy":"random","length":20,"encoding":"raw_text","scope":{"level":"team","team":"legal"}}}]"#,
)
.unwrap();
let ctx = RuntimeSecretContext {
bundle_root: bundle_root.to_path_buf(),
pack_paths: vec![bundle_root.join("packs/messaging-webex")],
environment: "dev".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: Vec::new(),
};
let reqs = collect_requirements(&ctx).unwrap();
assert_eq!(reqs.len(), 1);
assert!(
reqs[0].uri.starts_with("secrets://dev/demo/legal/"),
"generated secret must be team-scoped, got {}",
reqs[0].uri,
);
let alias_uri = canonical_secret_uri(
"dev",
"demo",
Some("legal"),
&reqs[0].provider_id,
"legacy_webhook",
);
let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
{
let store = DevStore::with_path(&store_path).unwrap();
store
.put(
&alias_uri,
greentic_secrets_lib::SecretFormat::Text,
b"team-scoped-value",
)
.await
.unwrap();
}
let resolution = resolve_runtime_secrets(&ctx, &reqs).await;
assert!(
resolution.missing.is_empty(),
"team-scoped alias value must resolve: {:?}",
resolution.missing,
);
assert_eq!(resolution.resolved.len(), 1);
assert_eq!(resolution.resolved[0].value.expose(), "team-scoped-value");
}
fn req(uri: &str, provider_id: &str, key: &str) -> RuntimeSecretRequirement {
RuntimeSecretRequirement {
uri: uri.into(),
provider_id: provider_id.into(),
key: key.into(),
required: true,
default_value: None,
aliases: Vec::new(),
generated: None,
source: PathBuf::from("environment.json"),
}
}
async fn seed_dev_store_value(bundle_root: &Path, uri: &str, value: &[u8]) {
use greentic_secrets_lib::{DevStore, SecretFormat};
let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
let store = DevStore::with_path(&store_path).unwrap();
store.put(uri, SecretFormat::Text, value).await.unwrap();
}
fn ctx_for(bundle_root: &Path, environment: &str, tenant: &str) -> RuntimeSecretContext {
RuntimeSecretContext {
bundle_root: bundle_root.to_path_buf(),
pack_paths: Vec::new(),
environment: environment.into(),
tenant: tenant.into(),
team: None,
extra_dev_store_roots: Vec::new(),
}
}
#[test]
fn is_placeholder_secret_value_matches_only_exact_start_markers() {
let uri = "secrets://dev/demo/_/openai/api_key";
assert!(is_placeholder_secret_value("ollama-placeholder", uri));
assert!(is_placeholder_secret_value(" ollama-placeholder ", uri));
assert!(is_placeholder_secret_value(
"placeholder for secrets://dev/demo/_/openai/api_key",
uri
));
assert!(
!is_placeholder_secret_value("placeholder for secrets://dev/demo/_/other/key", uri),
"the templated marker must not match a different URI"
);
assert!(!is_placeholder_secret_value(
"placeholder for my passphrase",
uri
));
assert!(!is_placeholder_secret_value("sk-realkey-123", uri));
assert!(!is_placeholder_secret_value("", uri));
assert!(!is_placeholder_secret_value("${OPENAI_API_KEY}", uri));
assert!(!is_placeholder_secret_value("ollama", uri));
}
#[tokio::test]
async fn resolve_treats_start_placeholder_as_unresolved_required_is_missing() {
let dir = tempfile::tempdir().unwrap();
let uri = "secrets://dev/demo/_/openai/api_key";
seed_dev_store_value(dir.path(), uri, b"ollama-placeholder").await;
let ctx = ctx_for(dir.path(), "dev", "demo");
let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "openai", "api_key")]).await;
assert!(
resolution.resolved.is_empty(),
"a placeholder must not resolve"
);
assert_eq!(resolution.missing.len(), 1);
assert_eq!(resolution.missing[0].requirement.uri, uri);
assert!(
resolution.missing[0]
.checked_sources
.iter()
.any(|s| s.contains("auto-seeded placeholder")),
"the ignored placeholder source must be recorded for the operator: {:?}",
resolution.missing[0].checked_sources,
);
}
#[tokio::test]
async fn resolve_skips_optional_placeholder_so_cloud_value_survives() {
let dir = tempfile::tempdir().unwrap();
let uri = "secrets://dev/demo/_/openai/api_key";
seed_dev_store_value(dir.path(), uri, b"ollama-placeholder").await;
let ctx = ctx_for(dir.path(), "dev", "demo");
let mut requirement = req(uri, "openai", "api_key");
requirement.required = false;
let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
assert!(resolution.resolved.is_empty());
assert!(
resolution.missing.is_empty(),
"an optional placeholder is skipped, not reported missing"
);
}
#[tokio::test]
async fn resolve_ignores_uri_scoped_placeholder_for_marker() {
let dir = tempfile::tempdir().unwrap();
let uri = "secrets://dev/demo/_/webhook/token";
seed_dev_store_value(
dir.path(),
uri,
b"placeholder for secrets://dev/demo/_/webhook/token",
)
.await;
let ctx = ctx_for(dir.path(), "dev", "demo");
let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "webhook", "token")]).await;
assert!(resolution.resolved.is_empty());
assert_eq!(resolution.missing.len(), 1);
}
#[tokio::test]
async fn resolve_keeps_real_value_that_merely_starts_with_placeholder_for() {
let dir = tempfile::tempdir().unwrap();
let uri = "secrets://dev/demo/_/app/passphrase";
let real = b"placeholder for the vault, rotated monthly";
seed_dev_store_value(dir.path(), uri, real).await;
let ctx = ctx_for(dir.path(), "dev", "demo");
let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "app", "passphrase")]).await;
assert!(resolution.missing.is_empty());
assert_eq!(resolution.resolved.len(), 1);
assert_eq!(
resolution.resolved[0].value.expose(),
"placeholder for the vault, rotated monthly"
);
}
#[tokio::test]
async fn resolve_still_accepts_a_real_value_after_placeholder_guard() {
let dir = tempfile::tempdir().unwrap();
let uri = "secrets://dev/demo/_/openai/api_key";
seed_dev_store_value(dir.path(), uri, b"sk-realkey-123").await;
let ctx = ctx_for(dir.path(), "dev", "demo");
let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "openai", "api_key")]).await;
assert!(resolution.missing.is_empty());
assert_eq!(resolution.resolved.len(), 1);
assert_eq!(resolution.resolved[0].value.expose(), "sk-realkey-123");
}
fn env_with_webhook_endpoint(
env_id: &str,
eid: MessagingEndpointId,
with_ref: bool,
) -> Environment {
use chrono::Utc;
use greentic_deploy_spec::{
EnvironmentHostConfig, MessagingEndpoint, SchemaVersion, SecretRef,
};
let eid_lower = eid.to_string().to_lowercase();
let webhook_secret_ref = with_ref.then(|| {
SecretRef::try_new(format!(
"secret://{env_id}/default/_/messaging-{eid_lower}/webhook_secret"
))
.unwrap()
});
let endpoint = MessagingEndpoint {
schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
env_id: EnvId::try_from(env_id).unwrap(),
endpoint_id: eid,
provider_id: "tg-legal".into(),
provider_type: "telegram".into(),
display_name: "Legal Bot".into(),
secret_refs: Vec::new(),
webhook_secret_ref,
linked_bundles: Vec::new(),
welcome_flow: None,
generation: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
updated_by: "operator://test".into(),
};
Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: EnvId::try_from(env_id).unwrap(),
name: env_id.into(),
host_config: EnvironmentHostConfig {
env_id: EnvId::try_from(env_id).unwrap(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
},
packs: Vec::new(),
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
messaging_endpoints: vec![endpoint],
extensions: Vec::new(),
revocation: Default::default(),
retention: Default::default(),
health: Default::default(),
}
}
#[test]
fn endpoint_webhook_requirement_matches_runtime_read_uri() {
let eid = MessagingEndpointId::new();
let eid_lower = eid.to_string().to_lowercase();
let env = env_with_webhook_endpoint("prod", eid, true);
let reqs = collect_endpoint_webhook_requirements(&env).unwrap();
assert_eq!(reqs.len(), 1);
let req = &reqs[0];
assert_eq!(
req.uri,
format!("secrets://prod/default/_/messaging-{eid_lower}/webhook_secret")
);
assert_eq!(req.provider_id, format!("messaging-{eid_lower}"));
assert_eq!(req.key, "webhook_secret");
assert!(req.required);
assert!(req.generated.is_none());
}
#[test]
fn endpoint_without_webhook_ref_yields_no_requirement() {
let env = env_with_webhook_endpoint("prod", MessagingEndpointId::new(), false);
assert!(
collect_endpoint_webhook_requirements(&env)
.unwrap()
.is_empty()
);
}
#[test]
fn dev_store_paths_includes_extra_env_roots_deduped() {
let bundle = PathBuf::from("/bundle");
let env_dir = PathBuf::from("/env");
let paths = dev_store_paths(&bundle, std::slice::from_ref(&env_dir));
assert!(paths.contains(&bundle.join(".greentic/dev/.dev.secrets.env")));
assert!(paths.contains(&bundle.join(".greentic/state/dev/.dev.secrets.env")));
assert!(paths.contains(&env_dir.join(".greentic/dev/.dev.secrets.env")));
assert!(paths.contains(&env_dir.join(".greentic/state/dev/.dev.secrets.env")));
let deduped = dev_store_paths(&bundle, std::slice::from_ref(&bundle));
let unique: BTreeSet<_> = deduped.iter().cloned().collect();
assert_eq!(deduped.len(), unique.len());
}
#[test]
fn cloud_remote_name_for_webhook_is_endpoint_scoped() {
let req = req(
"secrets://prod/default/_/messaging-abc/webhook_secret",
"messaging-abc",
WEBHOOK_SECRET_KEY,
);
let prefix = default_cloud_secret_prefix("prod", "acme", None);
assert_eq!(
cloud_remote_name(Provider::Aws, &prefix, &req).unwrap(),
"greentic/prod/acme/_/messaging_abc/webhook_secret"
);
let mut req2 = req.clone();
req2.provider_id = "messaging-xyz".into();
assert_ne!(
cloud_remote_name(Provider::Aws, &prefix, &req),
cloud_remote_name(Provider::Aws, &prefix, &req2)
);
}
#[tokio::test]
async fn resolve_finds_webhook_value_in_env_dir_dev_store() {
let bundle = tempfile::tempdir().unwrap();
let env_dir = tempfile::tempdir().unwrap();
let uri = "secrets://prod/default/_/messaging-abc/webhook_secret";
let store_path = env_dir.path().join(".greentic/dev/.dev.secrets.env");
std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
{
let store = DevStore::with_path(&store_path).unwrap();
store
.put(uri, greentic_secrets_lib::SecretFormat::Text, b"wh-value")
.await
.unwrap();
}
let ctx = RuntimeSecretContext {
bundle_root: bundle.path().to_path_buf(),
pack_paths: Vec::new(),
environment: "prod".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: vec![env_dir.path().to_path_buf()],
};
let requirement = req(uri, "messaging-abc", WEBHOOK_SECRET_KEY);
let resolution = resolve_runtime_secrets(&ctx, std::slice::from_ref(&requirement)).await;
assert!(resolution.missing.is_empty(), "{:?}", resolution.missing);
assert_eq!(resolution.resolved.len(), 1);
assert_eq!(resolution.resolved[0].value.expose(), "wh-value");
}
#[tokio::test]
async fn resolve_misses_webhook_value_without_env_root() {
let bundle = tempfile::tempdir().unwrap();
let env_dir = tempfile::tempdir().unwrap();
let uri = "secrets://prod/default/_/messaging-abc/webhook_secret";
let store_path = env_dir.path().join(".greentic/dev/.dev.secrets.env");
std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
{
let store = DevStore::with_path(&store_path).unwrap();
store
.put(uri, greentic_secrets_lib::SecretFormat::Text, b"wh-value")
.await
.unwrap();
}
let ctx = RuntimeSecretContext {
bundle_root: bundle.path().to_path_buf(),
pack_paths: Vec::new(),
environment: "prod".into(),
tenant: "demo".into(),
team: None,
extra_dev_store_roots: Vec::new(),
};
let requirement = req(uri, "messaging-abc", WEBHOOK_SECRET_KEY);
let resolution = resolve_runtime_secrets(&ctx, std::slice::from_ref(&requirement)).await;
assert_eq!(resolution.missing.len(), 1);
}
#[test]
fn load_environment_from_store_returns_none_when_absent() {
let dir = tempfile::tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let env_id = EnvId::try_from("prod").unwrap();
assert!(
load_environment_from_store(&store, &env_id)
.unwrap()
.is_none()
);
}
#[test]
fn load_environment_from_store_fails_closed_on_corrupt_env() {
let dir = tempfile::tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let env_id = EnvId::try_from("prod").unwrap();
let env_path = dir.path().join("prod/environment.json");
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
std::fs::write(&env_path, b"{ not valid json").unwrap();
assert!(load_environment_from_store(&store, &env_id).is_err());
}
#[test]
fn build_cloud_env_map_pack_wins_on_uri_collision() {
let uri = "secrets://prod/default/_/messaging-abc/webhook_secret".to_string();
let pack = req(&uri, "packprov", "api_key");
let ep_collide = req(&uri, "messaging-abc", WEBHOOK_SECRET_KEY);
let ep_other = req(
"secrets://prod/default/_/messaging-xyz/webhook_secret",
"messaging-xyz",
WEBHOOK_SECRET_KEY,
);
let resolved: BTreeSet<String> = [
uri.clone(),
"secrets://prod/default/_/messaging-xyz/webhook_secret".to_string(),
]
.into_iter()
.collect();
let prefix = default_cloud_secret_prefix("prod", "acme", None);
let map = build_cloud_env_map(
Provider::Aws,
&prefix,
std::slice::from_ref(&pack),
&[ep_collide, ep_other],
&resolved,
);
assert_eq!(
map.get(&uri).unwrap(),
&cloud_remote_name(Provider::Aws, &prefix, &pack).unwrap()
);
assert!(map.contains_key("secrets://prod/default/_/messaging-xyz/webhook_secret"));
}
}