1use std::{
2 collections::{BTreeMap, BTreeSet},
3 env, fmt,
4 fs::File,
5 io::{Read, Seek, SeekFrom},
6 path::{Path, PathBuf},
7};
8
9use greentic_secrets_lib::{
10 DevStore, GeneratedSecretRequirement, GeneratedSecretScope, SecretsStore, TEAM_PLACEHOLDER,
11 canonical_secret_name, canonical_secret_store_key, generated_scope_team, normalize_team,
12};
13use serde::Deserialize;
14use sha2::{Digest, Sha256};
15use zip::{ZipArchive, result::ZipError};
16
17use crate::config::{DeployerConfig, Provider};
18use crate::contract::DeployerCapability;
19use crate::environment::{EnvironmentStore, LocalFsStore};
20use crate::error::{DeployerError, Result};
21use greentic_deploy_spec::{EnvId, Environment};
22
23const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
24const SECRET_ASSET_PATHS: &[&str] = &[
25 "assets/secret-requirements.json",
26 "assets/secret_requirements.json",
27 "secret-requirements.json",
28 "secret_requirements.json",
29];
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct RuntimeSecretRequirement {
33 pub uri: String,
34 pub provider_id: String,
35 pub key: String,
36 pub required: bool,
37 pub default_value: Option<String>,
38 pub aliases: Vec<String>,
42 pub generated: Option<GeneratedSecretRequirement>,
48 pub source: PathBuf,
49}
50
51#[derive(Clone, PartialEq, Eq)]
52pub struct SecretValue(String);
53
54impl SecretValue {
55 pub fn expose(&self) -> &str {
56 &self.0
57 }
58}
59
60impl From<String> for SecretValue {
61 fn from(value: String) -> Self {
62 Self(value)
63 }
64}
65
66impl fmt::Debug for SecretValue {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 f.write_str("<redacted>")
69 }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct ResolvedRuntimeSecret {
74 pub requirement: RuntimeSecretRequirement,
75 pub value: SecretValue,
76 pub source: SecretValueSource,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum SecretValueSource {
81 Env { key: String },
82 DevStore { path: PathBuf },
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct MissingRuntimeSecret {
87 pub requirement: RuntimeSecretRequirement,
88 pub checked_sources: Vec<String>,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct RuntimeSecretResolution {
93 pub resolved: Vec<ResolvedRuntimeSecret>,
94 pub missing: Vec<MissingRuntimeSecret>,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct PromotedRuntimeSecret {
99 pub uri: String,
100 pub remote_name: String,
101}
102
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
104pub struct PromoteRuntimeSecretsReport {
105 pub promoted: Vec<PromotedRuntimeSecret>,
106 pub skipped: Vec<String>,
107}
108
109#[derive(Clone, Debug)]
110pub struct RuntimeSecretContext {
111 pub bundle_root: PathBuf,
112 pub pack_paths: Vec<PathBuf>,
113 pub environment: String,
114 pub tenant: String,
115 pub team: Option<String>,
116 pub extra_dev_store_roots: Vec<PathBuf>,
122}
123
124struct CloudSecretInputs {
132 ctx: RuntimeSecretContext,
133 pack_requirements: Vec<RuntimeSecretRequirement>,
134 endpoint_requirements: Vec<RuntimeSecretRequirement>,
135}
136
137fn collect_cloud_secret_inputs(config: &DeployerConfig) -> Result<Option<CloudSecretInputs>> {
138 let Some(bundle_root) = config.bundle_root.clone().or_else(|| {
139 config
140 .pack_path
141 .as_deref()
142 .and_then(infer_bundle_root_from_pack_path)
143 }) else {
144 return Ok(None);
145 };
146
147 let mut pack_paths: Vec<PathBuf> = config.pack_path.clone().into_iter().collect();
148 if let Some(provider_pack) = config.provider_pack.as_ref() {
149 pack_paths.push(provider_pack.clone());
150 }
151 pack_paths.extend(discover_bundle_pack_paths(&bundle_root)?);
152 pack_paths = dedup_paths(pack_paths);
153
154 let loaded_env = load_environment_for_config(config)?;
155 let extra_dev_store_roots = loaded_env
156 .as_ref()
157 .map(|(_, env_dir)| vec![env_dir.clone()])
158 .unwrap_or_default();
159 let ctx = RuntimeSecretContext {
160 bundle_root,
161 pack_paths,
162 environment: config.environment.clone(),
163 tenant: config.tenant.clone(),
164 team: None,
165 extra_dev_store_roots,
166 };
167
168 let pack_requirements = collect_requirements(&ctx)?;
169 let mut endpoint_requirements = match loaded_env.as_ref() {
170 Some((env, _)) => collect_endpoint_webhook_requirements(env)?,
171 None => Vec::new(),
172 };
173 if !endpoint_requirements.is_empty() {
177 let pack_uris: BTreeSet<&str> = pack_requirements.iter().map(|r| r.uri.as_str()).collect();
178 endpoint_requirements.retain(|r| !pack_uris.contains(r.uri.as_str()));
179 }
180
181 Ok(Some(CloudSecretInputs {
182 ctx,
183 pack_requirements,
184 endpoint_requirements,
185 }))
186}
187
188pub async fn resolve_for_cloud_apply(
189 config: &DeployerConfig,
190) -> Result<Option<RuntimeSecretResolution>> {
191 if !matches!(
192 config.provider,
193 Provider::Aws | Provider::Azure | Provider::Gcp
194 ) || config.capability != DeployerCapability::Apply
195 || !config.execute_local
196 {
197 return Ok(None);
198 }
199 let Some(CloudSecretInputs {
200 ctx,
201 mut pack_requirements,
202 endpoint_requirements,
203 }) = collect_cloud_secret_inputs(config)?
204 else {
205 return Ok(None);
206 };
207 pack_requirements.extend(endpoint_requirements);
211 if pack_requirements.is_empty() {
212 return Ok(None);
213 }
214
215 let resolution = resolve_runtime_secrets(&ctx, &pack_requirements).await;
216 if !resolution.missing.is_empty() {
217 return Err(DeployerError::Config(format_missing_runtime_secrets(
218 &resolution.missing,
219 )));
220 }
221 Ok(Some(resolution))
222}
223
224fn load_environment_for_config(config: &DeployerConfig) -> Result<Option<(Environment, PathBuf)>> {
225 let Ok(env_id) = EnvId::try_from(config.environment.as_str()) else {
230 return Ok(None);
231 };
232 let Some(root) = LocalFsStore::default_root() else {
233 return Ok(None);
234 };
235 load_environment_from_store(&LocalFsStore::new(root), &env_id)
236}
237
238fn load_environment_from_store(
243 store: &LocalFsStore,
244 env_id: &EnvId,
245) -> Result<Option<(Environment, PathBuf)>> {
246 if !store
247 .exists(env_id)
248 .map_err(|e| DeployerError::Config(format!("environment store: {e}")))?
249 {
250 return Ok(None);
251 }
252 let env = store
253 .load(env_id)
254 .map_err(|e| DeployerError::Config(format!("load environment {env_id}: {e}")))?;
255 let env_dir = store
256 .env_dir(env_id)
257 .map_err(|e| DeployerError::Config(format!("environment dir {env_id}: {e}")))?;
258 Ok(Some((env, env_dir)))
259}
260
261const WEBHOOK_SECRET_KEY: &str = "webhook_secret";
262
263fn collect_endpoint_webhook_requirements(
271 env: &Environment,
272) -> Result<Vec<RuntimeSecretRequirement>> {
273 let mut out = Vec::new();
274 for endpoint in &env.messaging_endpoints {
275 let Some(secret_ref) = endpoint.webhook_secret_ref.as_ref() else {
276 continue;
277 };
278 let uri = secret_ref
279 .to_store_uri()
280 .map_err(|e| {
281 DeployerError::Config(format!(
282 "messaging endpoint {} webhook_secret_ref: {e}",
283 endpoint.endpoint_id
284 ))
285 })?
286 .to_string();
287 let eid_lower = endpoint.endpoint_id.to_string().to_lowercase();
288 out.push(RuntimeSecretRequirement {
289 uri,
290 provider_id: format!("messaging-{eid_lower}"),
291 key: WEBHOOK_SECRET_KEY.to_string(),
292 required: true,
293 default_value: None,
294 aliases: Vec::new(),
295 generated: None,
296 source: PathBuf::from("environment.json"),
297 });
298 }
299 Ok(out)
300}
301
302fn cloud_remote_name(
305 provider: Provider,
306 prefix: &str,
307 requirement: &RuntimeSecretRequirement,
308) -> Option<String> {
309 match provider {
310 Provider::Aws => Some(cloud_secret_name(
311 prefix,
312 &requirement.provider_id,
313 &requirement.key,
314 )),
315 Provider::Azure => Some(flat_cloud_secret_name(
316 prefix,
317 &requirement.provider_id,
318 &requirement.key,
319 127,
320 )),
321 Provider::Gcp => Some(flat_cloud_secret_name(
322 prefix,
323 &requirement.provider_id,
324 &requirement.key,
325 255,
326 )),
327 _ => None,
328 }
329}
330
331fn build_cloud_env_map(
332 provider: Provider,
333 prefix: &str,
334 pack_requirements: &[RuntimeSecretRequirement],
335 endpoint_requirements: &[RuntimeSecretRequirement],
336 resolved_uris: &BTreeSet<String>,
337) -> BTreeMap<String, String> {
338 let mut env_map = BTreeMap::new();
339 for requirement in pack_requirements {
340 if !requirement.required && !resolved_uris.contains(&requirement.uri) {
341 continue;
342 }
343 let Some(remote_name) = cloud_remote_name(provider, prefix, requirement) else {
344 continue;
345 };
346 env_map.insert(requirement.uri.clone(), remote_name.clone());
347 env_map.insert(requirement.key.clone(), remote_name);
348 }
349 for requirement in endpoint_requirements {
355 if env_map.contains_key(&requirement.uri) {
356 continue;
357 }
358 if !requirement.required && !resolved_uris.contains(&requirement.uri) {
359 continue;
360 }
361 let Some(remote_name) = cloud_remote_name(provider, prefix, requirement) else {
362 continue;
363 };
364 env_map.insert(requirement.uri.clone(), remote_name);
365 }
366 env_map
367}
368
369pub fn default_cloud_secret_prefix(environment: &str, tenant: &str, team: Option<&str>) -> String {
370 let team = normalize_team(team);
371 format!(
372 "greentic/{environment}/{tenant}/{}",
373 team.as_deref().unwrap_or(TEAM_PLACEHOLDER)
374 )
375}
376
377pub fn collect_requirements(ctx: &RuntimeSecretContext) -> Result<Vec<RuntimeSecretRequirement>> {
378 let mut by_uri = BTreeMap::new();
379 for pack_path in &ctx.pack_paths {
380 if !pack_path.exists() {
381 continue;
382 }
383 let provider_id = provider_id_from_pack_path(pack_path);
384 for req in load_secret_requirements_from_pack(pack_path)? {
385 let key = canonical_secret_name(&req.key);
386 let generated = req
387 .generated
388 .as_ref()
389 .map(AssetGeneratedSecret::to_requirement);
390 let team = match &generated {
395 Some(generated) => generated_scope_team(generated, ctx.team.as_deref()),
396 None => ctx.team.as_deref(),
397 };
398 let uri = canonical_secret_uri(&ctx.environment, &ctx.tenant, team, &provider_id, &key);
399 let aliases = req
400 .aliases
401 .iter()
402 .map(|alias| canonical_secret_name(alias))
403 .collect();
404 by_uri
405 .entry(uri.clone())
406 .or_insert(RuntimeSecretRequirement {
407 uri,
408 provider_id: provider_id.clone(),
409 key,
410 required: req.required,
411 default_value: req.default_value,
412 aliases,
413 generated,
414 source: pack_path.clone(),
415 });
416 }
417 }
418 Ok(by_uri.into_values().collect())
419}
420
421pub fn bundle_secret_requirements(
432 bundle_root: &Path,
433 environment: &str,
434 tenant: &str,
435) -> Result<Vec<RuntimeSecretRequirement>> {
436 let pack_paths = discover_bundle_pack_paths(bundle_root)?;
437 let ctx = RuntimeSecretContext {
438 bundle_root: bundle_root.to_path_buf(),
439 pack_paths,
440 environment: environment.to_string(),
441 tenant: tenant.to_string(),
442 team: None,
443 extra_dev_store_roots: Vec::new(),
444 };
445 collect_requirements(&ctx)
446}
447
448pub fn manifest_secret_path(uri: &str, environment: &str) -> Option<String> {
453 uri.strip_prefix(&format!("secrets://{environment}/"))
454 .map(str::to_string)
455}
456
457pub fn runtime_secret_env_map_for_cloud(
458 config: &DeployerConfig,
459) -> Result<BTreeMap<String, String>> {
460 if !matches!(
461 config.provider,
462 Provider::Aws | Provider::Azure | Provider::Gcp
463 ) {
464 return Ok(BTreeMap::new());
465 }
466 let Some(CloudSecretInputs {
467 ctx,
468 pack_requirements,
469 endpoint_requirements,
470 }) = collect_cloud_secret_inputs(config)?
471 else {
472 return Ok(BTreeMap::new());
473 };
474 let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
475
476 let all_requirements: Vec<RuntimeSecretRequirement> = pack_requirements
484 .iter()
485 .chain(endpoint_requirements.iter())
486 .cloned()
487 .collect();
488 let resolution = block_on_async_resolution(&ctx, &all_requirements);
489 let resolved_uris: BTreeSet<String> = resolution
490 .resolved
491 .into_iter()
492 .map(|r| r.requirement.uri)
493 .collect();
494
495 let env_map = build_cloud_env_map(
496 config.provider,
497 &prefix,
498 &pack_requirements,
499 &endpoint_requirements,
500 &resolved_uris,
501 );
502 Ok(env_map)
503}
504
505fn block_on_async_resolution(
522 ctx: &RuntimeSecretContext,
523 requirements: &[RuntimeSecretRequirement],
524) -> RuntimeSecretResolution {
525 std::thread::scope(|scope| {
526 scope
527 .spawn(|| {
528 tokio::runtime::Builder::new_current_thread()
529 .enable_all()
530 .build()
531 .expect("build runtime for runtime-secret resolution")
532 .block_on(resolve_runtime_secrets(ctx, requirements))
533 })
534 .join()
535 .expect("runtime-secret resolution thread panicked")
536 })
537}
538
539pub async fn resolve_runtime_secrets(
540 ctx: &RuntimeSecretContext,
541 requirements: &[RuntimeSecretRequirement],
542) -> RuntimeSecretResolution {
543 let store_paths = dev_store_paths(&ctx.bundle_root, &ctx.extra_dev_store_roots);
544 let mut resolved = Vec::new();
545 let mut missing = Vec::new();
546
547 for requirement in requirements {
548 let mut checked_sources = Vec::new();
549 let team = match &requirement.generated {
555 Some(generated) => generated_scope_team(generated, ctx.team.as_deref()),
556 None => ctx.team.as_deref(),
557 };
558 let candidate_uris: Vec<String> = std::iter::once(requirement.uri.clone())
559 .chain(requirement.aliases.iter().map(|alias| {
560 canonical_secret_uri(
561 &ctx.environment,
562 &ctx.tenant,
563 team,
564 &requirement.provider_id,
565 alias,
566 )
567 }))
568 .collect();
569
570 let mut found = None;
571 'candidates: for uri in &candidate_uris {
572 if let Some(env_key) = canonical_secret_store_key(uri) {
573 checked_sources.push(format!("env {env_key}"));
574 if let Ok(value) = env::var(&env_key)
575 && !value.is_empty()
576 {
577 if is_placeholder_secret_value(&value, uri) {
578 checked_sources
579 .push(format!("env {env_key} (auto-seeded placeholder, ignored)"));
580 } else {
581 found = Some((SecretValueSource::Env { key: env_key }, value));
582 break 'candidates;
583 }
584 }
585 }
586
587 for path in &store_paths {
588 checked_sources.push(path.display().to_string());
589 if !path.exists() {
590 continue;
591 }
592 if let Ok(store) = DevStore::with_path(path)
593 && let Ok(bytes) = store.get(uri).await
594 && let Ok(value) = String::from_utf8(bytes)
595 && !value.is_empty()
596 {
597 if let Some(env_key) = extract_env_placeholder(&value) {
604 checked_sources.push(format!("env ${{{env_key}}} (from dev store)"));
605 match env::var(&env_key) {
606 Ok(expanded)
607 if !expanded.is_empty()
608 && !is_placeholder_secret_value(&expanded, uri) =>
609 {
610 found = Some((
611 SecretValueSource::DevStore { path: path.clone() },
612 expanded,
613 ));
614 break 'candidates;
615 }
616 _ => continue,
617 }
618 }
619 if is_placeholder_secret_value(&value, uri) {
626 checked_sources.push(format!(
627 "{} (auto-seeded placeholder, ignored)",
628 path.display()
629 ));
630 continue;
631 }
632 found = Some((SecretValueSource::DevStore { path: path.clone() }, value));
633 break 'candidates;
634 }
635 }
636 }
637
638 if let Some((source, value)) = found {
639 resolved.push(ResolvedRuntimeSecret {
640 requirement: requirement.clone(),
641 value: SecretValue(value),
642 source,
643 });
644 } else if requirement.required {
645 missing.push(MissingRuntimeSecret {
646 requirement: requirement.clone(),
647 checked_sources,
648 });
649 }
650 }
651
652 RuntimeSecretResolution { resolved, missing }
653}
654
655pub fn format_missing_runtime_secrets(missing: &[MissingRuntimeSecret]) -> String {
656 let mut out = String::from("missing required runtime secrets:\n");
657 for entry in missing {
658 out.push_str(&format!(" - {}\n", entry.requirement.uri));
659 if entry.requirement.generated.is_some() {
660 out.push_str(
661 " (system-generated — run `gtc setup` / provisioning to mint it before deploy)\n",
662 );
663 }
664 out.push_str(" checked:\n");
665 for source in &entry.checked_sources {
666 out.push_str(&format!(" - {source}\n"));
667 }
668 }
669 out
670}
671
672pub fn cloud_secret_name(prefix: &str, provider_id: &str, key: &str) -> String {
673 format!(
674 "{}/{}/{}",
675 prefix.trim_matches('/'),
676 canonical_secret_name(provider_id),
677 canonical_secret_name(key)
678 )
679}
680
681pub fn flat_cloud_secret_name(
682 prefix: &str,
683 provider_id: &str,
684 key: &str,
685 max_len: usize,
686) -> String {
687 let raw = format!("{}-{}-{}", prefix.trim_matches('/'), provider_id, key);
688 let mut normalized = String::with_capacity(raw.len());
689 let mut prev_dash = false;
690 for ch in raw.chars() {
691 let next = match ch {
692 'A'..='Z' => ch.to_ascii_lowercase(),
693 'a'..='z' | '0'..='9' => ch,
694 '-' => '-',
695 '_' | '/' | '.' | ' ' => '-',
696 _ => continue,
697 };
698 if next == '-' {
699 if prev_dash {
700 continue;
701 }
702 prev_dash = true;
703 } else {
704 prev_dash = false;
705 }
706 normalized.push(next);
707 }
708 let normalized = normalized.trim_matches('-');
709 if normalized.len() <= max_len {
710 return normalized.to_string();
711 }
712
713 let mut hasher = Sha256::new();
714 hasher.update(normalized.as_bytes());
715 let digest = hex::encode(hasher.finalize());
716 let suffix = format!("-{}", &digest[..12]);
717 let keep = max_len.saturating_sub(suffix.len());
718 format!("{}{}", normalized[..keep].trim_matches('-'), suffix)
719}
720
721pub fn canonical_secret_uri(
731 env: &str,
732 tenant: &str,
733 team: Option<&str>,
734 provider: &str,
735 key: &str,
736) -> String {
737 let team = normalize_team(team);
738 format!(
739 "secrets://{}/{}/{}/{}/{}",
740 env,
741 tenant,
742 team.as_deref().unwrap_or(TEAM_PLACEHOLDER),
743 provider,
744 canonical_secret_name(key)
745 )
746}
747
748fn dev_store_paths(bundle_root: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
749 let mut paths = Vec::new();
750 if let Some(path) = env::var_os(DEV_SECRETS_PATH_ENV) {
751 paths.push(PathBuf::from(path));
752 }
753 for root in std::iter::once(bundle_root).chain(extra_roots.iter().map(PathBuf::as_path)) {
754 paths.push(root.join(".greentic/dev/.dev.secrets.env"));
755 paths.push(root.join(".greentic/state/dev/.dev.secrets.env"));
756 }
757
758 let mut seen = BTreeSet::new();
759 paths
760 .into_iter()
761 .filter(|path| seen.insert(path.clone()))
762 .collect()
763}
764
765fn discover_bundle_pack_paths(bundle_root: &Path) -> Result<Vec<PathBuf>> {
766 let mut out = Vec::new();
767 collect_pack_paths_from_dir(&bundle_root.join("packs"), &mut out)?;
768 collect_pack_paths_from_dir(&bundle_root.join("providers"), &mut out)?;
769 out.sort();
770 Ok(out)
771}
772
773fn collect_pack_paths_from_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
774 if !dir.exists() {
775 return Ok(());
776 }
777 for entry in std::fs::read_dir(dir)? {
778 let entry = entry?;
779 let path = entry.path();
780 if path.extension().and_then(|value| value.to_str()) == Some("gtpack") {
781 out.push(path);
782 continue;
783 }
784 if path.is_dir() {
785 if path.join("pack.yaml").exists() || path.join("manifest.cbor").exists() {
786 out.push(path);
787 } else {
788 collect_pack_paths_from_dir(&path, out)?;
789 }
790 }
791 }
792 Ok(())
793}
794
795fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
796 let mut seen = BTreeSet::new();
797 paths
798 .into_iter()
799 .filter(|path| seen.insert(path.clone()))
800 .collect()
801}
802
803fn provider_id_from_pack_path(pack_path: &Path) -> String {
804 pack_path
805 .file_stem()
806 .and_then(|value| value.to_str())
807 .map(str::trim)
808 .filter(|value| !value.is_empty())
809 .map(ToOwned::to_owned)
810 .unwrap_or_else(|| "provider".to_string())
811}
812
813fn infer_bundle_root_from_pack_path(pack_path: &Path) -> Option<PathBuf> {
814 let mut current = if pack_path.is_dir() {
815 Some(pack_path)
816 } else {
817 pack_path.parent()
818 };
819 while let Some(path) = current {
820 if path.file_name().and_then(|value| value.to_str()) == Some("packs") {
821 return path.parent().map(Path::to_path_buf);
822 }
823 if path.join("bundle.yaml").exists() {
824 return Some(path.to_path_buf());
825 }
826 current = path.parent();
827 }
828 None
829}
830
831fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
832 if pack_path.is_dir() {
833 return load_secret_requirements_from_dir(pack_path);
834 }
835 if !is_probably_zip(pack_path)? {
836 return load_secret_requirements_from_tar(pack_path);
837 }
838 load_secret_requirements_from_zip(pack_path)
839}
840
841fn is_probably_zip(path: &Path) -> Result<bool> {
842 let mut file = File::open(path)?;
843 let mut magic = [0_u8; 4];
844 let read = file.read(&mut magic)?;
845 Ok(read == magic.len() && magic == [0x50, 0x4b, 0x03, 0x04])
846}
847
848fn is_probably_tar(path: &Path) -> Result<bool> {
849 let mut file = File::open(path)?;
850 file.seek(SeekFrom::Start(257))?;
851 let mut magic = [0_u8; 5];
852 let read = file.read(&mut magic)?;
853 Ok(read == magic.len() && magic == *b"ustar")
854}
855
856fn load_secret_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
857 let mut requirements = Vec::new();
858 for asset in SECRET_ASSET_PATHS {
859 let path = pack_path.join(asset);
860 if path.exists() {
861 let contents = std::fs::read_to_string(&path)?;
862 requirements.extend(parse_requirements(&contents, &path)?);
863 }
864 }
865 let setup_yaml = pack_path.join("assets/setup.yaml");
866 if setup_yaml.exists() {
867 let contents = std::fs::read_to_string(&setup_yaml)?;
868 requirements.extend(parse_setup_secret_requirements(&contents, &setup_yaml)?);
869 }
870 Ok(dedup_requirements(requirements))
871}
872
873fn load_secret_requirements_from_zip(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
874 let file = File::open(pack_path)?;
875 let mut archive = match ZipArchive::new(file) {
876 Ok(archive) => archive,
877 Err(_) => return Ok(Vec::new()),
878 };
879 let mut requirements = Vec::new();
880 for asset in SECRET_ASSET_PATHS {
881 match archive.by_name(asset) {
882 Ok(mut entry) => {
883 let mut contents = String::new();
884 entry.read_to_string(&mut contents)?;
885 requirements.extend(parse_requirements(&contents, Path::new(asset))?);
886 }
887 Err(ZipError::FileNotFound) => continue,
888 Err(err) => return Err(DeployerError::Other(err.to_string())),
889 }
890 }
891 if let Ok(mut entry) = archive.by_name("assets/setup.yaml") {
892 let mut contents = String::new();
893 entry.read_to_string(&mut contents)?;
894 requirements.extend(parse_setup_secret_requirements(
895 &contents,
896 Path::new("assets/setup.yaml"),
897 )?);
898 }
899 Ok(dedup_requirements(requirements))
900}
901
902fn load_secret_requirements_from_tar(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
903 if !is_probably_tar(pack_path)? {
904 return Ok(Vec::new());
905 }
906 let file = File::open(pack_path)?;
907 let mut archive = tar::Archive::new(file);
908 let entries = match archive.entries() {
909 Ok(entries) => entries,
910 Err(_) => return Ok(Vec::new()),
911 };
912 let mut requirements = Vec::new();
913 for entry in entries {
914 let mut entry = match entry {
915 Ok(entry) => entry,
916 Err(_) => continue,
917 };
918 let path = match entry.path() {
919 Ok(path) => path.into_owned(),
920 Err(_) => continue,
921 };
922 let Some(path_str) = path.to_str() else {
923 continue;
924 };
925 if SECRET_ASSET_PATHS.contains(&path_str) {
926 let mut contents = String::new();
927 entry.read_to_string(&mut contents)?;
928 requirements.extend(parse_requirements(&contents, &path)?);
929 } else if path_str == "assets/setup.yaml" {
930 let mut contents = String::new();
931 entry.read_to_string(&mut contents)?;
932 requirements.extend(parse_setup_secret_requirements(&contents, &path)?);
933 }
934 }
935 Ok(dedup_requirements(requirements))
936}
937
938fn parse_requirements(contents: &str, path: &Path) -> Result<Vec<PackSecretRequirement>> {
939 serde_json::from_str(contents).map_err(|err| {
940 DeployerError::Config(format!(
941 "parse secret requirements from {}: {err}",
942 path.display()
943 ))
944 })
945}
946
947#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
948struct PackSecretRequirement {
949 key: String,
950 #[serde(default)]
951 aliases: Vec<String>,
952 #[serde(default = "default_required")]
953 required: bool,
954 #[serde(default)]
955 default_value: Option<String>,
956 #[serde(default)]
957 generated: Option<AssetGeneratedSecret>,
958}
959
960#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
967struct AssetGeneratedSecret {
968 policy: Option<String>,
969 length: Option<usize>,
970 encoding: Option<String>,
971 scope: Option<AssetGeneratedSecretScope>,
972 #[serde(default)]
973 regenerate_if_present: Option<bool>,
974}
975
976#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
977struct AssetGeneratedSecretScope {
978 level: Option<String>,
979 team: Option<String>,
980}
981
982impl AssetGeneratedSecret {
983 fn to_requirement(&self) -> GeneratedSecretRequirement {
984 GeneratedSecretRequirement {
985 policy: self.policy.clone().unwrap_or_else(|| "random".to_string()),
986 length: self.length.unwrap_or(32),
987 encoding: self
988 .encoding
989 .clone()
990 .unwrap_or_else(|| "base64url".to_string()),
991 scope: GeneratedSecretScope {
992 level: self
993 .scope
994 .as_ref()
995 .and_then(|scope| scope.level.clone())
996 .unwrap_or_else(|| "team".to_string()),
997 team: self.scope.as_ref().and_then(|scope| scope.team.clone()),
998 },
999 regenerate_if_present: self.regenerate_if_present.unwrap_or(false),
1000 }
1001 }
1002}
1003
1004#[derive(Debug, Deserialize)]
1005struct SetupSpec {
1006 #[serde(default)]
1007 questions: Vec<SetupQuestion>,
1008}
1009
1010#[derive(Debug, Deserialize)]
1011struct SetupQuestion {
1012 name: String,
1013 #[serde(default)]
1014 secret_key: Option<String>,
1015 #[serde(default)]
1016 default: Option<String>,
1017 #[serde(default)]
1018 secret: bool,
1019 #[serde(default)]
1020 required: bool,
1021}
1022
1023fn parse_setup_secret_requirements(
1024 contents: &str,
1025 path: &Path,
1026) -> Result<Vec<PackSecretRequirement>> {
1027 let setup: SetupSpec = serde_yaml_bw::from_str(contents).map_err(|err| {
1028 DeployerError::Config(format!(
1029 "parse setup secrets from {}: {err}",
1030 path.display()
1031 ))
1032 })?;
1033 Ok(setup
1034 .questions
1035 .into_iter()
1036 .filter(|question| question.secret)
1037 .map(|question| PackSecretRequirement {
1038 key: question.secret_key.unwrap_or(question.name),
1039 aliases: Vec::new(),
1040 required: question.required,
1041 default_value: question.default,
1042 generated: None,
1043 })
1044 .collect())
1045}
1046
1047fn dedup_requirements(requirements: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
1048 let mut by_key = BTreeMap::new();
1049 for requirement in requirements {
1050 let key = canonical_secret_name(&requirement.key);
1051 by_key
1052 .entry(key)
1053 .and_modify(|existing: &mut PackSecretRequirement| {
1054 existing.required |= requirement.required;
1055 if existing.default_value.is_none() {
1056 existing.default_value = requirement.default_value.clone();
1057 }
1058 if existing.aliases.is_empty() {
1059 existing.aliases = requirement.aliases.clone();
1060 }
1061 if existing.generated.is_none() {
1062 existing.generated = requirement.generated.clone();
1063 }
1064 })
1065 .or_insert(requirement);
1066 }
1067 by_key.into_values().collect()
1068}
1069
1070fn extract_env_placeholder(value: &str) -> Option<String> {
1077 let trimmed = value.trim();
1078 let inner = trimmed.strip_prefix("${")?.strip_suffix('}')?;
1079 if inner.is_empty() || inner.contains(|c: char| c.is_whitespace() || c == '$' || c == '{') {
1080 return None;
1081 }
1082 Some(inner.to_string())
1083}
1084
1085fn is_placeholder_secret_value(value: &str, uri: &str) -> bool {
1103 let trimmed = value.trim();
1104 trimmed == "ollama-placeholder"
1105 || trimmed
1106 .strip_prefix("placeholder for ")
1107 .is_some_and(|rest| rest == uri)
1108}
1109
1110fn default_required() -> bool {
1111 true
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 use greentic_deploy_spec::MessagingEndpointId;
1118
1119 #[test]
1120 fn extract_env_placeholder_matches_whole_string_dollar_brace_form() {
1121 assert_eq!(
1122 extract_env_placeholder("${REDIS_URL}").as_deref(),
1123 Some("REDIS_URL")
1124 );
1125 assert_eq!(
1126 extract_env_placeholder("${OPENAI_API_KEY}").as_deref(),
1127 Some("OPENAI_API_KEY")
1128 );
1129 assert_eq!(
1130 extract_env_placeholder(" ${PUBLIC_BASE_URL} ").as_deref(),
1131 Some("PUBLIC_BASE_URL"),
1132 "surrounding whitespace is allowed"
1133 );
1134 }
1135
1136 #[test]
1137 fn extract_env_placeholder_rejects_partial_or_malformed_patterns() {
1138 assert_eq!(extract_env_placeholder("redis://host:6379/0"), None);
1139 assert_eq!(extract_env_placeholder("prefix-${VAR}"), None);
1140 assert_eq!(extract_env_placeholder("${VAR}-suffix"), None);
1141 assert_eq!(extract_env_placeholder("${}"), None);
1142 assert_eq!(extract_env_placeholder("${VAR WITH SPACE}"), None);
1143 assert_eq!(extract_env_placeholder("${NESTED${INNER}}"), None);
1144 }
1145
1146 #[test]
1147 fn canonical_env_key_matches_start_runtime_shape() {
1148 assert_eq!(
1149 canonical_secret_store_key("secrets://dev/demo/_/openai/api_key").as_deref(),
1150 Some("GREENTIC_SECRET__DEV__DEMO_____OPENAI__API_KEY")
1151 );
1152 }
1153
1154 #[test]
1155 fn cloud_secret_name_is_stable_and_normalized() {
1156 assert_eq!(
1157 cloud_secret_name(
1158 "greentic/dev/demo/_",
1159 "messaging-telegram",
1160 "TELEGRAM_BOT_TOKEN"
1161 ),
1162 "greentic/dev/demo/_/messaging_telegram/telegram_bot_token"
1163 );
1164 }
1165
1166 #[test]
1167 fn requirement_uri_preserves_pack_provider_id_hyphens() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let pack_dir = dir.path().join("packs/messaging-webchat-gui/assets");
1170 std::fs::create_dir_all(&pack_dir).unwrap();
1171 std::fs::write(
1172 pack_dir.join("secret-requirements.json"),
1173 r#"[{"key":"jwt_signing_key","required":true}]"#,
1174 )
1175 .unwrap();
1176
1177 let ctx = RuntimeSecretContext {
1178 bundle_root: dir.path().to_path_buf(),
1179 pack_paths: vec![dir.path().join("packs/messaging-webchat-gui")],
1180 environment: "dev".into(),
1181 tenant: "demo".into(),
1182 team: None,
1183 extra_dev_store_roots: Vec::new(),
1184 };
1185
1186 let requirements = collect_requirements(&ctx).unwrap();
1187 assert_eq!(requirements.len(), 1);
1188 assert_eq!(requirements[0].provider_id, "messaging-webchat-gui");
1189 assert_eq!(
1190 requirements[0].uri,
1191 "secrets://dev/demo/_/messaging-webchat-gui/jwt_signing_key"
1192 );
1193 assert_eq!(
1194 cloud_secret_name(
1195 "greentic/dev/demo/_",
1196 &requirements[0].provider_id,
1197 &requirements[0].key
1198 ),
1199 "greentic/dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1200 );
1201 }
1202
1203 #[test]
1204 fn bundle_secret_requirements_reads_packs_dir_scoped_to_tenant() {
1205 let dir = tempfile::tempdir().unwrap();
1210 let pack_dir = dir.path().join("packs/messaging-telegram");
1211 std::fs::create_dir_all(pack_dir.join("assets")).unwrap();
1212 std::fs::write(pack_dir.join("pack.yaml"), "id: messaging-telegram\n").unwrap();
1216 std::fs::write(
1217 pack_dir.join("assets/secret-requirements.json"),
1218 r#"[{"key":"TELEGRAM_BOT_TOKEN","required":true}]"#,
1219 )
1220 .unwrap();
1221
1222 let reqs = bundle_secret_requirements(dir.path(), "local", "legal").unwrap();
1223 assert_eq!(reqs.len(), 1);
1224 assert_eq!(
1225 reqs[0].uri,
1226 "secrets://local/legal/_/messaging-telegram/telegram_bot_token"
1227 );
1228 assert_eq!(
1230 manifest_secret_path(&reqs[0].uri, "local").as_deref(),
1231 Some("legal/_/messaging-telegram/telegram_bot_token")
1232 );
1233 }
1234
1235 #[test]
1236 fn bundle_secret_requirements_empty_when_unbuilt() {
1237 let dir = tempfile::tempdir().unwrap();
1239 let reqs = bundle_secret_requirements(dir.path(), "local", "legal").unwrap();
1240 assert!(reqs.is_empty());
1241 }
1242
1243 #[test]
1244 fn manifest_secret_path_rejects_foreign_env_or_scheme() {
1245 assert_eq!(
1246 manifest_secret_path("secrets://prod/legal/_/p/tok", "local"),
1247 None,
1248 "different env is not stripped"
1249 );
1250 assert_eq!(
1251 manifest_secret_path("https://example.com/x", "local"),
1252 None,
1253 "non-secrets scheme is rejected"
1254 );
1255 }
1256
1257 #[test]
1258 fn flat_secret_name_limits_length_with_digest() {
1259 let name = flat_cloud_secret_name(
1260 "greentic/dev/demo/default",
1261 "very-long-provider-name",
1262 "THIS_IS_A_VERY_LONG_SECRET_NAME",
1263 40,
1264 );
1265 assert!(name.len() <= 40);
1266 assert!(name.starts_with("greentic-dev-demo-default"));
1267 }
1268
1269 #[test]
1270 fn infers_bundle_root_from_pack_path_under_packs_dir() {
1271 let path = Path::new("/tmp/demo-bundle/packs/app.gtpack");
1272 assert_eq!(
1273 infer_bundle_root_from_pack_path(path).as_deref(),
1274 Some(Path::new("/tmp/demo-bundle"))
1275 );
1276 }
1277
1278 #[test]
1279 fn skips_non_zip_gtpack_when_scanning_secret_requirements() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let pack = dir.path().join("aws.gtpack");
1282 std::fs::write(&pack, b"not a zip").unwrap();
1283 let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1284 assert!(reqs.is_empty());
1285 }
1286
1287 #[test]
1288 fn reads_secret_requirements_from_tar_gtpack() {
1289 let dir = tempfile::tempdir().unwrap();
1290 let pack = dir.path().join("provider.gtpack");
1291 let file = File::create(&pack).unwrap();
1292 let mut builder = tar::Builder::new(file);
1293 let contents = br#"[{"key":"API_TOKEN","required":true}]"#;
1294 let mut header = tar::Header::new_gnu();
1295 header.set_path("assets/secret-requirements.json").unwrap();
1296 header.set_size(contents.len() as u64);
1297 header.set_cksum();
1298 builder
1299 .append(&header, contents.as_slice())
1300 .expect("append tar entry");
1301 builder.finish().unwrap();
1302
1303 let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1304 assert_eq!(reqs.len(), 1);
1305 assert_eq!(reqs[0].key, "API_TOKEN");
1306 }
1307
1308 #[test]
1309 fn reads_secret_requirements_from_setup_yaml() {
1310 let dir = tempfile::tempdir().unwrap();
1311 let pack = dir.path().join("pack");
1312 std::fs::create_dir_all(pack.join("assets")).unwrap();
1313 std::fs::write(
1314 pack.join("assets/setup.yaml"),
1315 r#"
1316questions:
1317 - name: api_key
1318 secret: true
1319 required: true
1320 - name: display_name
1321 secret: false
1322"#,
1323 )
1324 .unwrap();
1325
1326 let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1327 assert_eq!(reqs.len(), 1);
1328 assert_eq!(reqs[0].key, "api_key");
1329 assert!(reqs[0].required);
1330 }
1331
1332 #[test]
1333 fn discovers_provider_pack_paths() {
1334 let dir = tempfile::tempdir().unwrap();
1335 std::fs::create_dir_all(dir.path().join("providers/messaging")).unwrap();
1336 std::fs::write(
1337 dir.path()
1338 .join("providers/messaging/messaging-webchat-gui.gtpack"),
1339 b"",
1340 )
1341 .unwrap();
1342
1343 let paths = discover_bundle_pack_paths(dir.path()).unwrap();
1344 assert_eq!(paths.len(), 1);
1345 assert!(paths[0].ends_with("messaging-webchat-gui.gtpack"));
1346 }
1347
1348 #[tokio::test]
1349 async fn resolve_runtime_secrets_no_longer_falls_back_to_setup_answers() {
1350 let dir = tempfile::tempdir().unwrap();
1354 let answers_dir = dir.path().join("state/config/demo-pack");
1355 std::fs::create_dir_all(&answers_dir).unwrap();
1356 std::fs::write(
1357 answers_dir.join("setup-answers.json"),
1358 r#"{"api_key":"STALE-PLAINTEXT-MUST-NOT-LEAK"}"#,
1359 )
1360 .unwrap();
1361 let ctx = RuntimeSecretContext {
1362 bundle_root: dir.path().to_path_buf(),
1363 pack_paths: Vec::new(),
1364 environment: "dev".into(),
1365 tenant: "demo".into(),
1366 team: None,
1367 extra_dev_store_roots: Vec::new(),
1368 };
1369 let requirement = RuntimeSecretRequirement {
1370 uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "api_key"),
1371 provider_id: "demo_pack".into(),
1372 key: "api_key".into(),
1373 required: true,
1374 default_value: None,
1375 aliases: Vec::new(),
1376 generated: None,
1377 source: dir.path().join("packs/demo-pack.gtpack"),
1378 };
1379
1380 let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1381 assert!(
1382 resolution.resolved.is_empty(),
1383 "no source means no resolution"
1384 );
1385 assert_eq!(resolution.missing.len(), 1);
1386 assert!(
1387 !resolution.missing[0]
1388 .checked_sources
1389 .iter()
1390 .any(|s| s.contains("setup-answers")),
1391 "setup-answers must not appear in checked_sources after B12a",
1392 );
1393 }
1394
1395 fn build_skips_unresolved_optional_fixture(
1396 bundle_root: &Path,
1397 ) -> (PathBuf, std::path::PathBuf) {
1398 let packs_dir = bundle_root.join("packs");
1399 let config_dir = bundle_root.join("state/config/demo-app");
1400 std::fs::create_dir_all(packs_dir.join("demo-app/assets")).unwrap();
1401 std::fs::create_dir_all(&config_dir).unwrap();
1402 std::fs::write(
1403 packs_dir.join("demo-app/assets/setup.yaml"),
1404 r#"
1405questions:
1406 - name: api_key
1407 secret: true
1408 required: false
1409 - name: oauth_client_secret
1410 secret: true
1411 required: false
1412 - name: jwt_signing_key
1413 secret: true
1414 required: true
1415"#,
1416 )
1417 .unwrap();
1418 std::fs::write(
1421 config_dir.join("setup-answers.json"),
1422 r#"{"api_key":"STALE-PLAINTEXT-MUST-NOT-LEAK"}"#,
1423 )
1424 .unwrap();
1425 (packs_dir.clone(), packs_dir.join("demo-app"))
1426 }
1427
1428 fn deployer_config_for_fixture(bundle_root: &Path, pack_path: PathBuf) -> DeployerConfig {
1429 DeployerConfig {
1430 capability: DeployerCapability::Apply,
1431 provider: Provider::Aws,
1432 strategy: "iac-only".into(),
1433 tenant: "demo".into(),
1434 environment: "dev".into(),
1435 pack_path: Some(pack_path),
1436 bundle_root: Some(bundle_root.to_path_buf()),
1437 providers_dir: PathBuf::from("providers/deployer"),
1438 packs_dir: PathBuf::from("packs"),
1439 provider_pack: None,
1440 pack_ref: None,
1441 distributor_url: None,
1442 distributor_token: None,
1443 preview: false,
1444 dry_run: false,
1445 execute_local: true,
1446 output: crate::config::OutputFormat::Json,
1447 greentic: greentic_config::ConfigResolver::new()
1448 .load()
1449 .unwrap()
1450 .config,
1451 provenance: greentic_config::ProvenanceMap::new(),
1452 config_warnings: Vec::new(),
1453 deploy_pack_id_override: None,
1454 deploy_flow_id_override: None,
1455 bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1456 bundle_digest: Some(
1457 "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1458 ),
1459 repo_registry_base: None,
1460 store_registry_base: None,
1461 }
1462 }
1463
1464 #[test]
1465 fn runtime_secret_env_map_skips_unresolved_optional_secrets() {
1466 let dir = tempfile::tempdir().unwrap();
1467 let bundle_root = dir.path();
1468 let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
1469 let config = deployer_config_for_fixture(bundle_root, pack_path);
1470
1471 let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1472 assert!(env_map.contains_key("secrets://dev/demo/_/demo-app/jwt_signing_key"));
1474 assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/api_key"));
1477 assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/oauth_client_secret"));
1478 assert!(!env_map.contains_key("oauth_client_secret"));
1479 }
1480
1481 fn seed_devstore_api_key(bundle_root: &Path) {
1482 use greentic_secrets_lib::{DevStore, SecretFormat};
1483 let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1484 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1485 let store = DevStore::with_path(&store_path).unwrap();
1486 tokio::runtime::Builder::new_current_thread()
1489 .enable_all()
1490 .build()
1491 .unwrap()
1492 .block_on(async {
1493 store
1494 .put(
1495 "secrets://dev/demo/_/demo-app/api_key",
1496 SecretFormat::Text,
1497 b"from-dev-store",
1498 )
1499 .await
1500 .unwrap();
1501 });
1502 }
1503
1504 fn assert_devstore_optional_in_env_map(env_map: &BTreeMap<String, String>) {
1505 assert!(
1506 env_map.contains_key("secrets://dev/demo/_/demo-app/api_key"),
1507 "optional secret with DevStore value MUST appear in env_map: {env_map:?}",
1508 );
1509 assert!(env_map.contains_key("secrets://dev/demo/_/demo-app/jwt_signing_key"));
1510 assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/oauth_client_secret"));
1512 }
1513
1514 #[test]
1515 fn runtime_secret_env_map_includes_optional_secrets_with_devstore_value() {
1516 let dir = tempfile::tempdir().unwrap();
1520 let bundle_root = dir.path();
1521 let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
1522 seed_devstore_api_key(bundle_root);
1523
1524 let config = deployer_config_for_fixture(bundle_root, pack_path);
1525 let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1526 assert_devstore_optional_in_env_map(&env_map);
1527 }
1528
1529 #[test]
1535 fn runtime_secret_env_map_callable_from_within_current_thread_runtime() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let bundle_root = dir.path();
1538 let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
1539 seed_devstore_api_key(bundle_root);
1540 let config = deployer_config_for_fixture(bundle_root, pack_path);
1541
1542 let rt = tokio::runtime::Builder::new_current_thread()
1543 .enable_all()
1544 .build()
1545 .unwrap();
1546 let env_map = rt
1547 .block_on(async { runtime_secret_env_map_for_cloud(&config) })
1548 .unwrap();
1549 assert_devstore_optional_in_env_map(&env_map);
1550 }
1551
1552 #[test]
1553 fn secret_value_debug_is_redacted() {
1554 let value = SecretValue("super-secret".to_string());
1555 assert_eq!(format!("{value:?}"), "<redacted>");
1556 }
1557
1558 #[tokio::test]
1559 async fn resolve_runtime_secrets_honors_alias_uris() {
1560 let dir = tempfile::tempdir().unwrap();
1563 let bundle_root = dir.path();
1564 let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1565 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1566 let alias_uri = canonical_secret_uri("dev", "demo", None, "demo_pack", "legacy_jwt_key");
1567 {
1568 let store = DevStore::with_path(&store_path).unwrap();
1569 store
1570 .put(
1571 &alias_uri,
1572 greentic_secrets_lib::SecretFormat::Text,
1573 b"aliased-value",
1574 )
1575 .await
1576 .unwrap();
1577 }
1578
1579 let ctx = RuntimeSecretContext {
1580 bundle_root: bundle_root.to_path_buf(),
1581 pack_paths: Vec::new(),
1582 environment: "dev".into(),
1583 tenant: "demo".into(),
1584 team: None,
1585 extra_dev_store_roots: Vec::new(),
1586 };
1587 let requirement = RuntimeSecretRequirement {
1588 uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "jwt_signing_key"),
1589 provider_id: "demo_pack".into(),
1590 key: "jwt_signing_key".into(),
1591 required: true,
1592 default_value: None,
1593 aliases: vec!["legacy_jwt_key".into()],
1594 generated: None,
1595 source: bundle_root.join("packs/demo-pack.gtpack"),
1596 };
1597
1598 let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1599 assert!(resolution.missing.is_empty(), "alias value must resolve");
1600 assert_eq!(resolution.resolved.len(), 1);
1601 assert_eq!(resolution.resolved[0].value.expose(), "aliased-value");
1602 }
1603
1604 #[test]
1605 fn collect_requirements_parses_generated_and_aliases() {
1606 let dir = tempfile::tempdir().unwrap();
1610 let pack_dir = dir.path().join("packs/messaging-webex/assets");
1611 std::fs::create_dir_all(&pack_dir).unwrap();
1612 std::fs::write(
1613 pack_dir.join("secret-requirements.json"),
1614 r#"[{"key":"webex_webhook_secret","aliases":["WEBEX_WEBHOOK_SECRET"],"required":true,"generated":{"policy":"random","length":20,"encoding":"raw_text","scope":{"level":"tenant","team":"_"}}}]"#,
1615 )
1616 .unwrap();
1617
1618 let ctx = RuntimeSecretContext {
1619 bundle_root: dir.path().to_path_buf(),
1620 pack_paths: vec![dir.path().join("packs/messaging-webex")],
1621 environment: "dev".into(),
1622 tenant: "demo".into(),
1623 team: None,
1624 extra_dev_store_roots: Vec::new(),
1625 };
1626 let reqs = collect_requirements(&ctx).unwrap();
1627 assert_eq!(reqs.len(), 1);
1628 let req = &reqs[0];
1629 assert_eq!(req.key, "webex_webhook_secret");
1630 assert_eq!(req.aliases, vec!["webex_webhook_secret".to_string()]);
1632 let generated = req.generated.as_ref().expect("generated policy parsed");
1633 assert_eq!(generated.policy, "random");
1634 assert_eq!(generated.length, 20);
1635 assert_eq!(generated.encoding, "raw_text");
1636 assert_eq!(generated.scope.level, "tenant");
1637 assert_eq!(generated.scope.team.as_deref(), Some("_"));
1638 }
1639
1640 #[tokio::test]
1641 async fn generated_secret_resolves_from_its_declared_team_scope() {
1642 let dir = tempfile::tempdir().unwrap();
1648 let bundle_root = dir.path();
1649 let pack_dir = bundle_root.join("packs/messaging-webex/assets");
1650 std::fs::create_dir_all(&pack_dir).unwrap();
1651 std::fs::write(
1652 pack_dir.join("secret-requirements.json"),
1653 r#"[{"key":"webex_webhook_secret","aliases":["legacy_webhook"],"required":true,"generated":{"policy":"random","length":20,"encoding":"raw_text","scope":{"level":"team","team":"legal"}}}]"#,
1654 )
1655 .unwrap();
1656
1657 let ctx = RuntimeSecretContext {
1658 bundle_root: bundle_root.to_path_buf(),
1659 pack_paths: vec![bundle_root.join("packs/messaging-webex")],
1660 environment: "dev".into(),
1661 tenant: "demo".into(),
1662 team: None,
1663 extra_dev_store_roots: Vec::new(),
1664 };
1665 let reqs = collect_requirements(&ctx).unwrap();
1666 assert_eq!(reqs.len(), 1);
1667 assert!(
1669 reqs[0].uri.starts_with("secrets://dev/demo/legal/"),
1670 "generated secret must be team-scoped, got {}",
1671 reqs[0].uri,
1672 );
1673
1674 let alias_uri = canonical_secret_uri(
1677 "dev",
1678 "demo",
1679 Some("legal"),
1680 &reqs[0].provider_id,
1681 "legacy_webhook",
1682 );
1683 let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1684 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1685 {
1686 let store = DevStore::with_path(&store_path).unwrap();
1687 store
1688 .put(
1689 &alias_uri,
1690 greentic_secrets_lib::SecretFormat::Text,
1691 b"team-scoped-value",
1692 )
1693 .await
1694 .unwrap();
1695 }
1696
1697 let resolution = resolve_runtime_secrets(&ctx, &reqs).await;
1698 assert!(
1699 resolution.missing.is_empty(),
1700 "team-scoped alias value must resolve: {:?}",
1701 resolution.missing,
1702 );
1703 assert_eq!(resolution.resolved.len(), 1);
1704 assert_eq!(resolution.resolved[0].value.expose(), "team-scoped-value");
1705 }
1706
1707 fn req(uri: &str, provider_id: &str, key: &str) -> RuntimeSecretRequirement {
1710 RuntimeSecretRequirement {
1711 uri: uri.into(),
1712 provider_id: provider_id.into(),
1713 key: key.into(),
1714 required: true,
1715 default_value: None,
1716 aliases: Vec::new(),
1717 generated: None,
1718 source: PathBuf::from("environment.json"),
1719 }
1720 }
1721
1722 async fn seed_dev_store_value(bundle_root: &Path, uri: &str, value: &[u8]) {
1723 use greentic_secrets_lib::{DevStore, SecretFormat};
1724 let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1725 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1726 let store = DevStore::with_path(&store_path).unwrap();
1727 store.put(uri, SecretFormat::Text, value).await.unwrap();
1728 }
1729
1730 fn ctx_for(bundle_root: &Path, environment: &str, tenant: &str) -> RuntimeSecretContext {
1731 RuntimeSecretContext {
1732 bundle_root: bundle_root.to_path_buf(),
1733 pack_paths: Vec::new(),
1734 environment: environment.into(),
1735 tenant: tenant.into(),
1736 team: None,
1737 extra_dev_store_roots: Vec::new(),
1738 }
1739 }
1740
1741 #[test]
1742 fn is_placeholder_secret_value_matches_only_exact_start_markers() {
1743 let uri = "secrets://dev/demo/_/openai/api_key";
1744 assert!(is_placeholder_secret_value("ollama-placeholder", uri));
1746 assert!(is_placeholder_secret_value(" ollama-placeholder ", uri));
1747 assert!(is_placeholder_secret_value(
1749 "placeholder for secrets://dev/demo/_/openai/api_key",
1750 uri
1751 ));
1752 assert!(
1753 !is_placeholder_secret_value("placeholder for secrets://dev/demo/_/other/key", uri),
1754 "the templated marker must not match a different URI"
1755 );
1756 assert!(!is_placeholder_secret_value(
1759 "placeholder for my passphrase",
1760 uri
1761 ));
1762 assert!(!is_placeholder_secret_value("sk-realkey-123", uri));
1763 assert!(!is_placeholder_secret_value("", uri));
1764 assert!(!is_placeholder_secret_value("${OPENAI_API_KEY}", uri));
1765 assert!(!is_placeholder_secret_value("ollama", uri));
1766 }
1767
1768 #[tokio::test]
1769 async fn resolve_treats_start_placeholder_as_unresolved_required_is_missing() {
1770 let dir = tempfile::tempdir().unwrap();
1775 let uri = "secrets://dev/demo/_/openai/api_key";
1776 seed_dev_store_value(dir.path(), uri, b"ollama-placeholder").await;
1777
1778 let ctx = ctx_for(dir.path(), "dev", "demo");
1779 let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "openai", "api_key")]).await;
1780
1781 assert!(
1782 resolution.resolved.is_empty(),
1783 "a placeholder must not resolve"
1784 );
1785 assert_eq!(resolution.missing.len(), 1);
1786 assert_eq!(resolution.missing[0].requirement.uri, uri);
1787 assert!(
1788 resolution.missing[0]
1789 .checked_sources
1790 .iter()
1791 .any(|s| s.contains("auto-seeded placeholder")),
1792 "the ignored placeholder source must be recorded for the operator: {:?}",
1793 resolution.missing[0].checked_sources,
1794 );
1795 }
1796
1797 #[tokio::test]
1798 async fn resolve_skips_optional_placeholder_so_cloud_value_survives() {
1799 let dir = tempfile::tempdir().unwrap();
1803 let uri = "secrets://dev/demo/_/openai/api_key";
1804 seed_dev_store_value(dir.path(), uri, b"ollama-placeholder").await;
1805
1806 let ctx = ctx_for(dir.path(), "dev", "demo");
1807 let mut requirement = req(uri, "openai", "api_key");
1808 requirement.required = false;
1809 let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1810
1811 assert!(resolution.resolved.is_empty());
1812 assert!(
1813 resolution.missing.is_empty(),
1814 "an optional placeholder is skipped, not reported missing"
1815 );
1816 }
1817
1818 #[tokio::test]
1819 async fn resolve_ignores_uri_scoped_placeholder_for_marker() {
1820 let dir = tempfile::tempdir().unwrap();
1823 let uri = "secrets://dev/demo/_/webhook/token";
1824 seed_dev_store_value(
1825 dir.path(),
1826 uri,
1827 b"placeholder for secrets://dev/demo/_/webhook/token",
1828 )
1829 .await;
1830
1831 let ctx = ctx_for(dir.path(), "dev", "demo");
1832 let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "webhook", "token")]).await;
1833
1834 assert!(resolution.resolved.is_empty());
1835 assert_eq!(resolution.missing.len(), 1);
1836 }
1837
1838 #[tokio::test]
1839 async fn resolve_keeps_real_value_that_merely_starts_with_placeholder_for() {
1840 let dir = tempfile::tempdir().unwrap();
1845 let uri = "secrets://dev/demo/_/app/passphrase";
1846 let real = b"placeholder for the vault, rotated monthly";
1847 seed_dev_store_value(dir.path(), uri, real).await;
1848
1849 let ctx = ctx_for(dir.path(), "dev", "demo");
1850 let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "app", "passphrase")]).await;
1851
1852 assert!(resolution.missing.is_empty());
1853 assert_eq!(resolution.resolved.len(), 1);
1854 assert_eq!(
1855 resolution.resolved[0].value.expose(),
1856 "placeholder for the vault, rotated monthly"
1857 );
1858 }
1859
1860 #[tokio::test]
1861 async fn resolve_still_accepts_a_real_value_after_placeholder_guard() {
1862 let dir = tempfile::tempdir().unwrap();
1864 let uri = "secrets://dev/demo/_/openai/api_key";
1865 seed_dev_store_value(dir.path(), uri, b"sk-realkey-123").await;
1866
1867 let ctx = ctx_for(dir.path(), "dev", "demo");
1868 let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "openai", "api_key")]).await;
1869
1870 assert!(resolution.missing.is_empty());
1871 assert_eq!(resolution.resolved.len(), 1);
1872 assert_eq!(resolution.resolved[0].value.expose(), "sk-realkey-123");
1873 }
1874
1875 fn env_with_webhook_endpoint(
1876 env_id: &str,
1877 eid: MessagingEndpointId,
1878 with_ref: bool,
1879 ) -> Environment {
1880 use chrono::Utc;
1881 use greentic_deploy_spec::{
1882 EnvironmentHostConfig, MessagingEndpoint, SchemaVersion, SecretRef,
1883 };
1884 let eid_lower = eid.to_string().to_lowercase();
1885 let webhook_secret_ref = with_ref.then(|| {
1886 SecretRef::try_new(format!(
1887 "secret://{env_id}/default/_/messaging-{eid_lower}/webhook_secret"
1888 ))
1889 .unwrap()
1890 });
1891 let endpoint = MessagingEndpoint {
1892 schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
1893 env_id: EnvId::try_from(env_id).unwrap(),
1894 endpoint_id: eid,
1895 provider_id: "tg-legal".into(),
1896 provider_type: "telegram".into(),
1897 display_name: "Legal Bot".into(),
1898 secret_refs: Vec::new(),
1899 webhook_secret_ref,
1900 linked_bundles: Vec::new(),
1901 welcome_flow: None,
1902 generation: 1,
1903 created_at: Utc::now(),
1904 updated_at: Utc::now(),
1905 updated_by: "operator://test".into(),
1906 };
1907 Environment {
1908 schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
1909 environment_id: EnvId::try_from(env_id).unwrap(),
1910 name: env_id.into(),
1911 host_config: EnvironmentHostConfig {
1912 env_id: EnvId::try_from(env_id).unwrap(),
1913 region: None,
1914 tenant_org_id: None,
1915 listen_addr: None,
1916 public_base_url: None,
1917 gui_enabled: None,
1918 },
1919 packs: Vec::new(),
1920 credentials_ref: None,
1921 bundles: Vec::new(),
1922 revisions: Vec::new(),
1923 traffic_splits: Vec::new(),
1924 messaging_endpoints: vec![endpoint],
1925 extensions: Vec::new(),
1926 revocation: Default::default(),
1927 retention: Default::default(),
1928 health: Default::default(),
1929 }
1930 }
1931
1932 #[test]
1933 fn endpoint_webhook_requirement_matches_runtime_read_uri() {
1934 let eid = MessagingEndpointId::new();
1935 let eid_lower = eid.to_string().to_lowercase();
1936 let env = env_with_webhook_endpoint("prod", eid, true);
1937
1938 let reqs = collect_endpoint_webhook_requirements(&env).unwrap();
1939 assert_eq!(reqs.len(), 1);
1940 let req = &reqs[0];
1941 assert_eq!(
1946 req.uri,
1947 format!("secrets://prod/default/_/messaging-{eid_lower}/webhook_secret")
1948 );
1949 assert_eq!(req.provider_id, format!("messaging-{eid_lower}"));
1950 assert_eq!(req.key, "webhook_secret");
1951 assert!(req.required);
1952 assert!(req.generated.is_none());
1953 }
1954
1955 #[test]
1956 fn endpoint_without_webhook_ref_yields_no_requirement() {
1957 let env = env_with_webhook_endpoint("prod", MessagingEndpointId::new(), false);
1958 assert!(
1959 collect_endpoint_webhook_requirements(&env)
1960 .unwrap()
1961 .is_empty()
1962 );
1963 }
1964
1965 #[test]
1966 fn dev_store_paths_includes_extra_env_roots_deduped() {
1967 let bundle = PathBuf::from("/bundle");
1968 let env_dir = PathBuf::from("/env");
1969 let paths = dev_store_paths(&bundle, std::slice::from_ref(&env_dir));
1970 assert!(paths.contains(&bundle.join(".greentic/dev/.dev.secrets.env")));
1971 assert!(paths.contains(&bundle.join(".greentic/state/dev/.dev.secrets.env")));
1972 assert!(paths.contains(&env_dir.join(".greentic/dev/.dev.secrets.env")));
1973 assert!(paths.contains(&env_dir.join(".greentic/state/dev/.dev.secrets.env")));
1974
1975 let deduped = dev_store_paths(&bundle, std::slice::from_ref(&bundle));
1977 let unique: BTreeSet<_> = deduped.iter().cloned().collect();
1978 assert_eq!(deduped.len(), unique.len());
1979 }
1980
1981 #[test]
1982 fn cloud_remote_name_for_webhook_is_endpoint_scoped() {
1983 let req = req(
1984 "secrets://prod/default/_/messaging-abc/webhook_secret",
1985 "messaging-abc",
1986 WEBHOOK_SECRET_KEY,
1987 );
1988 let prefix = default_cloud_secret_prefix("prod", "acme", None);
1989 assert_eq!(
1993 cloud_remote_name(Provider::Aws, &prefix, &req).unwrap(),
1994 "greentic/prod/acme/_/messaging_abc/webhook_secret"
1995 );
1996 let mut req2 = req.clone();
1998 req2.provider_id = "messaging-xyz".into();
1999 assert_ne!(
2000 cloud_remote_name(Provider::Aws, &prefix, &req),
2001 cloud_remote_name(Provider::Aws, &prefix, &req2)
2002 );
2003 }
2004
2005 #[tokio::test]
2006 async fn resolve_finds_webhook_value_in_env_dir_dev_store() {
2007 let bundle = tempfile::tempdir().unwrap();
2011 let env_dir = tempfile::tempdir().unwrap();
2012 let uri = "secrets://prod/default/_/messaging-abc/webhook_secret";
2013 let store_path = env_dir.path().join(".greentic/dev/.dev.secrets.env");
2014 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
2015 {
2016 let store = DevStore::with_path(&store_path).unwrap();
2017 store
2018 .put(uri, greentic_secrets_lib::SecretFormat::Text, b"wh-value")
2019 .await
2020 .unwrap();
2021 }
2022
2023 let ctx = RuntimeSecretContext {
2024 bundle_root: bundle.path().to_path_buf(),
2025 pack_paths: Vec::new(),
2026 environment: "prod".into(),
2027 tenant: "demo".into(),
2028 team: None,
2029 extra_dev_store_roots: vec![env_dir.path().to_path_buf()],
2030 };
2031 let requirement = req(uri, "messaging-abc", WEBHOOK_SECRET_KEY);
2032 let resolution = resolve_runtime_secrets(&ctx, std::slice::from_ref(&requirement)).await;
2033 assert!(resolution.missing.is_empty(), "{:?}", resolution.missing);
2034 assert_eq!(resolution.resolved.len(), 1);
2035 assert_eq!(resolution.resolved[0].value.expose(), "wh-value");
2036 }
2037
2038 #[tokio::test]
2039 async fn resolve_misses_webhook_value_without_env_root() {
2040 let bundle = tempfile::tempdir().unwrap();
2044 let env_dir = tempfile::tempdir().unwrap();
2045 let uri = "secrets://prod/default/_/messaging-abc/webhook_secret";
2046 let store_path = env_dir.path().join(".greentic/dev/.dev.secrets.env");
2047 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
2048 {
2049 let store = DevStore::with_path(&store_path).unwrap();
2050 store
2051 .put(uri, greentic_secrets_lib::SecretFormat::Text, b"wh-value")
2052 .await
2053 .unwrap();
2054 }
2055 let ctx = RuntimeSecretContext {
2056 bundle_root: bundle.path().to_path_buf(),
2057 pack_paths: Vec::new(),
2058 environment: "prod".into(),
2059 tenant: "demo".into(),
2060 team: None,
2061 extra_dev_store_roots: Vec::new(),
2062 };
2063 let requirement = req(uri, "messaging-abc", WEBHOOK_SECRET_KEY);
2064 let resolution = resolve_runtime_secrets(&ctx, std::slice::from_ref(&requirement)).await;
2065 assert_eq!(resolution.missing.len(), 1);
2066 }
2067
2068 #[test]
2069 fn load_environment_from_store_returns_none_when_absent() {
2070 let dir = tempfile::tempdir().unwrap();
2071 let store = LocalFsStore::new(dir.path());
2072 let env_id = EnvId::try_from("prod").unwrap();
2073 assert!(
2074 load_environment_from_store(&store, &env_id)
2075 .unwrap()
2076 .is_none()
2077 );
2078 }
2079
2080 #[test]
2081 fn load_environment_from_store_fails_closed_on_corrupt_env() {
2082 let dir = tempfile::tempdir().unwrap();
2083 let store = LocalFsStore::new(dir.path());
2084 let env_id = EnvId::try_from("prod").unwrap();
2085 let env_path = dir.path().join("prod/environment.json");
2086 std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
2087 std::fs::write(&env_path, b"{ not valid json").unwrap();
2088 assert!(load_environment_from_store(&store, &env_id).is_err());
2089 }
2090
2091 #[test]
2092 fn build_cloud_env_map_pack_wins_on_uri_collision() {
2093 let uri = "secrets://prod/default/_/messaging-abc/webhook_secret".to_string();
2094 let pack = req(&uri, "packprov", "api_key");
2095 let ep_collide = req(&uri, "messaging-abc", WEBHOOK_SECRET_KEY);
2096 let ep_other = req(
2097 "secrets://prod/default/_/messaging-xyz/webhook_secret",
2098 "messaging-xyz",
2099 WEBHOOK_SECRET_KEY,
2100 );
2101 let resolved: BTreeSet<String> = [
2102 uri.clone(),
2103 "secrets://prod/default/_/messaging-xyz/webhook_secret".to_string(),
2104 ]
2105 .into_iter()
2106 .collect();
2107 let prefix = default_cloud_secret_prefix("prod", "acme", None);
2108 let map = build_cloud_env_map(
2109 Provider::Aws,
2110 &prefix,
2111 std::slice::from_ref(&pack),
2112 &[ep_collide, ep_other],
2113 &resolved,
2114 );
2115 assert_eq!(
2118 map.get(&uri).unwrap(),
2119 &cloud_remote_name(Provider::Aws, &prefix, &pack).unwrap()
2120 );
2121 assert!(map.contains_key("secrets://prod/default/_/messaging-xyz/webhook_secret"));
2122 }
2123}