1use std::{
2 collections::{BTreeMap, BTreeSet},
3 env, fmt,
4 fs::File,
5 io::{Read, Seek, SeekFrom},
6 path::{Path, PathBuf},
7};
8
9use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
10use greentic_secrets_lib::{
11 DevStore, SecretsStore, TEAM_PLACEHOLDER, canonical_secret_name, canonical_secret_store_key,
12 normalize_team,
13};
14use greentic_types::{ExtensionInline, decode_pack_manifest};
15use rand::RngExt as _;
16use serde::Deserialize;
17use serde_cbor::value::Value as CborValue;
18use serde_json::Value as JsonValue;
19use sha2::{Digest, Sha256};
20use zip::{ZipArchive, result::ZipError};
21
22use crate::config::{DeployerConfig, Provider};
23use crate::contract::DeployerCapability;
24use crate::error::{DeployerError, Result};
25
26const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
27const EXT_GENERATED_SECRETS_V1: &str = "greentic.generated-secrets.v1";
28const SECRET_ASSET_PATHS: &[&str] = &[
29 "assets/secret-requirements.json",
30 "assets/secret_requirements.json",
31 "secret-requirements.json",
32 "secret_requirements.json",
33];
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct RuntimeSecretRequirement {
37 pub uri: String,
38 pub provider_id: String,
39 pub key: String,
40 pub required: bool,
41 pub default_value: Option<String>,
42 pub generated: Option<GeneratedSecretRequirement>,
43 pub source: PathBuf,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct GeneratedSecretRequirement {
48 pub policy: String,
49 pub length: usize,
50 pub encoding: String,
51 pub scope: GeneratedSecretScope,
52 pub regenerate_if_present: bool,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct GeneratedSecretScope {
57 pub level: String,
58 pub team: Option<String>,
59}
60
61#[derive(Clone, PartialEq, Eq)]
62pub struct SecretValue(String);
63
64impl SecretValue {
65 pub fn new(value: String) -> Self {
66 Self(value)
67 }
68
69 pub fn expose(&self) -> &str {
70 &self.0
71 }
72}
73
74impl fmt::Debug for SecretValue {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str("<redacted>")
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ResolvedRuntimeSecret {
82 pub requirement: RuntimeSecretRequirement,
83 pub value: SecretValue,
84 pub source: SecretValueSource,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum SecretValueSource {
89 Env { key: String },
90 DevStore { path: PathBuf },
91 SetupAnswers { path: PathBuf },
92 Generated,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct MissingRuntimeSecret {
97 pub requirement: RuntimeSecretRequirement,
98 pub checked_sources: Vec<String>,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct RuntimeSecretResolution {
103 pub resolved: Vec<ResolvedRuntimeSecret>,
104 pub missing: Vec<MissingRuntimeSecret>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct PromotedRuntimeSecret {
109 pub uri: String,
110 pub remote_name: String,
111}
112
113#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct PromoteRuntimeSecretsReport {
115 pub promoted: Vec<PromotedRuntimeSecret>,
116 pub skipped: Vec<String>,
117}
118
119#[derive(Clone, Debug)]
120pub struct RuntimeSecretContext {
121 pub bundle_root: PathBuf,
122 pub pack_paths: Vec<PathBuf>,
123 pub environment: String,
124 pub tenant: String,
125 pub team: Option<String>,
126}
127
128pub async fn resolve_for_cloud_apply(
129 config: &DeployerConfig,
130) -> Result<Option<RuntimeSecretResolution>> {
131 if !matches!(
132 config.provider,
133 Provider::Aws | Provider::Azure | Provider::Gcp
134 ) || config.capability != DeployerCapability::Apply
135 || !config.execute_local
136 {
137 return Ok(None);
138 }
139 let Some(bundle_root) = config
140 .bundle_root
141 .clone()
142 .or_else(|| infer_bundle_root_from_pack_path(&config.pack_path))
143 else {
144 return Ok(None);
145 };
146
147 let pack_paths = pack_paths_for_cloud_apply(config, &bundle_root)?;
148
149 let ctx = RuntimeSecretContext {
150 bundle_root,
151 pack_paths,
152 environment: config.environment.clone(),
153 tenant: config.tenant.clone(),
154 team: None,
155 };
156 let requirements = collect_requirements(&ctx)?;
157 if requirements.is_empty() {
158 return Ok(None);
159 }
160
161 let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
162 if !resolution.missing.is_empty() {
163 return Err(DeployerError::Config(format_missing_runtime_secrets(
164 &resolution.missing,
165 )));
166 }
167 Ok(Some(resolution))
168}
169
170pub fn default_cloud_secret_prefix(environment: &str, tenant: &str, team: Option<&str>) -> String {
171 let team = normalize_team(team);
172 format!(
173 "greentic/{environment}/{tenant}/{}",
174 team.as_deref().unwrap_or(TEAM_PLACEHOLDER)
175 )
176}
177
178pub fn collect_requirements(ctx: &RuntimeSecretContext) -> Result<Vec<RuntimeSecretRequirement>> {
179 let mut by_uri = BTreeMap::new();
180 for pack_path in &ctx.pack_paths {
181 if !pack_path.exists() {
182 continue;
183 }
184 let provider_id = provider_id_from_pack_path(pack_path);
185 for req in load_secret_requirements_from_pack(pack_path)? {
186 let key = canonical_secret_name(&req.key);
187 let uri = canonical_secret_uri(
188 &ctx.environment,
189 &ctx.tenant,
190 requirement_team(req.generated.as_ref(), ctx.team.as_deref()),
191 &provider_id,
192 &key,
193 );
194 by_uri
195 .entry(uri.clone())
196 .or_insert(RuntimeSecretRequirement {
197 uri,
198 provider_id: provider_id.clone(),
199 key,
200 required: req.required,
201 default_value: req.default_value,
202 generated: req.generated,
203 source: pack_path.clone(),
204 });
205 }
206 }
207 Ok(by_uri.into_values().collect())
208}
209
210fn pack_paths_for_cloud_apply(config: &DeployerConfig, bundle_root: &Path) -> Result<Vec<PathBuf>> {
211 let mut pack_paths = vec![config.pack_path.clone()];
212 if let Some(provider_pack) = config.provider_pack.as_ref() {
213 pack_paths.push(provider_pack.clone());
214 }
215 pack_paths.extend(
216 discover_bundle_pack_paths(bundle_root)?
217 .into_iter()
218 .filter(|path| include_pack_for_cloud_provider(config.provider, bundle_root, path)),
219 );
220 Ok(dedup_paths(pack_paths))
221}
222
223fn include_pack_for_cloud_provider(
224 provider: Provider,
225 bundle_root: &Path,
226 pack_path: &Path,
227) -> bool {
228 let Ok(relative) = pack_path.strip_prefix(bundle_root) else {
229 return true;
230 };
231 let mut components = relative.components();
232 let Some(std::path::Component::Normal(first)) = components.next() else {
233 return true;
234 };
235 let Some(std::path::Component::Normal(second)) = components.next() else {
236 return true;
237 };
238 if first != "providers" || second != "secrets" {
239 return true;
240 }
241 let Some(active_stem) = active_secrets_provider_pack_stem(provider) else {
242 return false;
243 };
244 provider_id_from_pack_path(pack_path) == active_stem
245}
246
247fn active_secrets_provider_pack_stem(provider: Provider) -> Option<&'static str> {
248 match provider {
249 Provider::Aws => Some("aws-sm"),
250 Provider::Gcp => Some("gcp-sm"),
251 Provider::Azure => Some("azure-kv"),
252 _ => None,
253 }
254}
255
256pub fn runtime_secret_env_map_for_cloud(
257 _config: &DeployerConfig,
258) -> Result<BTreeMap<String, String>> {
259 Ok(BTreeMap::new())
263}
264
265pub async fn resolve_runtime_secrets(
266 ctx: &RuntimeSecretContext,
267 requirements: &[RuntimeSecretRequirement],
268) -> RuntimeSecretResolution {
269 let store_paths = dev_store_paths(&ctx.bundle_root);
270 let mut resolved = Vec::new();
271 let mut missing = Vec::new();
272
273 for requirement in requirements {
274 let mut checked_sources = Vec::new();
275 if let Some(env_key) = canonical_secret_store_key(&requirement.uri) {
276 checked_sources.push(format!("env {env_key}"));
277 if let Ok(value) = env::var(&env_key)
278 && !value.is_empty()
279 {
280 resolved.push(ResolvedRuntimeSecret {
281 requirement: requirement.clone(),
282 value: SecretValue(value),
283 source: SecretValueSource::Env { key: env_key },
284 });
285 continue;
286 }
287 }
288
289 let mut found = None;
290 for path in &store_paths {
291 checked_sources.push(path.display().to_string());
292 if !path.exists() {
293 continue;
294 }
295 if let Ok(store) = DevStore::with_path(path)
296 && let Ok(bytes) = store.get(&requirement.uri).await
297 && let Ok(value) = String::from_utf8(bytes)
298 && !value.is_empty()
299 {
300 if let Some(env_key) = extract_env_placeholder(&value) {
308 checked_sources.push(format!("env ${{{env_key}}} (from dev store)"));
309 match env::var(&env_key) {
310 Ok(resolved) if !resolved.is_empty() => {
311 found = Some((path.clone(), resolved));
312 break;
313 }
314 _ => continue,
315 }
316 }
317 found = Some((path.clone(), value));
318 break;
319 }
320 }
321
322 if let Some((path, value)) = found {
323 resolved.push(ResolvedRuntimeSecret {
324 requirement: requirement.clone(),
325 value: SecretValue(value),
326 source: SecretValueSource::DevStore { path },
327 });
328 } else if let Some((path, value)) =
329 resolve_from_setup_answers(&ctx.bundle_root, requirement, &mut checked_sources)
330 {
331 resolved.push(ResolvedRuntimeSecret {
332 requirement: requirement.clone(),
333 value: SecretValue(value),
334 source: SecretValueSource::SetupAnswers { path },
335 });
336 } else if let Some(generated) = &requirement.generated {
337 checked_sources.push("generated secret metadata".to_string());
345 match generated_secret_value(generated) {
346 Ok(value) => {
347 tracing::warn!(
348 secret_uri = %requirement.uri,
349 secret_key = %requirement.key,
350 "generated runtime secret was absent from the local secrets manager; \
351 generating at deploy as a transitional fallback (setup should introduce it)"
352 );
353 resolved.push(ResolvedRuntimeSecret {
354 requirement: requirement.clone(),
355 value: SecretValue(value),
356 source: SecretValueSource::Generated,
357 });
358 }
359 Err(err) if requirement.required => {
360 checked_sources.push(format!("generation failed: {err}"));
361 tracing::warn!(
362 secret_uri = %requirement.uri,
363 secret_key = %requirement.key,
364 "required runtime secret could not be generated"
365 );
366 missing.push(MissingRuntimeSecret {
367 requirement: requirement.clone(),
368 checked_sources,
369 });
370 }
371 Err(_) => {}
372 }
373 } else if requirement.required {
374 tracing::warn!(
375 secret_uri = %requirement.uri,
376 secret_key = %requirement.key,
377 checked_sources = ?checked_sources,
378 "required runtime secret is not available in the local secrets manager"
379 );
380 missing.push(MissingRuntimeSecret {
381 requirement: requirement.clone(),
382 checked_sources,
383 });
384 } else {
385 tracing::warn!(
389 secret_uri = %requirement.uri,
390 secret_key = %requirement.key,
391 "optional runtime secret not found in the local secrets manager; \
392 leaving unset for runtime resolution"
393 );
394 }
395 }
396
397 RuntimeSecretResolution { resolved, missing }
398}
399
400pub fn format_missing_runtime_secrets(missing: &[MissingRuntimeSecret]) -> String {
401 let mut out = String::from("missing required runtime secrets:\n");
402 for entry in missing {
403 out.push_str(&format!(" - {}\n", entry.requirement.uri));
404 out.push_str(" checked:\n");
405 for source in &entry.checked_sources {
406 out.push_str(&format!(" - {source}\n"));
407 }
408 }
409 out
410}
411
412pub fn cloud_secret_name(prefix: &str, provider_id: &str, key: &str) -> String {
413 format!(
414 "{}/{}/{}",
415 prefix.trim_matches('/'),
416 canonical_secret_name(provider_id),
417 canonical_secret_name(key)
418 )
419}
420
421pub fn flat_cloud_secret_name(
422 prefix: &str,
423 provider_id: &str,
424 key: &str,
425 max_len: usize,
426) -> String {
427 let raw = format!("{}-{}-{}", prefix.trim_matches('/'), provider_id, key);
428 let mut normalized = String::with_capacity(raw.len());
429 let mut prev_dash = false;
430 for ch in raw.chars() {
431 let next = match ch {
432 'A'..='Z' => ch.to_ascii_lowercase(),
433 'a'..='z' | '0'..='9' => ch,
434 '-' => '-',
435 '_' | '/' | '.' | ' ' => '-',
436 _ => continue,
437 };
438 if next == '-' {
439 if prev_dash {
440 continue;
441 }
442 prev_dash = true;
443 } else {
444 prev_dash = false;
445 }
446 normalized.push(next);
447 }
448 let normalized = normalized.trim_matches('-');
449 if normalized.len() <= max_len {
450 return normalized.to_string();
451 }
452
453 let mut hasher = Sha256::new();
454 hasher.update(normalized.as_bytes());
455 let digest = hex::encode(hasher.finalize());
456 let suffix = format!("-{}", &digest[..12]);
457 let keep = max_len.saturating_sub(suffix.len());
458 format!("{}{}", normalized[..keep].trim_matches('-'), suffix)
459}
460
461pub fn canonical_secret_uri(
462 env: &str,
463 tenant: &str,
464 team: Option<&str>,
465 provider: &str,
466 key: &str,
467) -> String {
468 let team = normalize_team(team);
469 format!(
474 "secrets://{}/{}/{}/{}/{}",
475 env,
476 tenant,
477 team.as_deref().unwrap_or(TEAM_PLACEHOLDER),
478 canonical_secret_name(provider),
479 canonical_secret_name(key)
480 )
481}
482
483fn requirement_team<'a>(
484 generated: Option<&'a GeneratedSecretRequirement>,
485 default_team: Option<&'a str>,
486) -> Option<&'a str> {
487 let Some(generated) = generated else {
488 return default_team;
489 };
490 if generated.scope.level.eq_ignore_ascii_case("tenant")
491 || generated.scope.team.as_deref() == Some("_")
492 {
493 return None;
494 }
495 generated.scope.team.as_deref().or(default_team)
496}
497
498fn dev_store_paths(bundle_root: &Path) -> Vec<PathBuf> {
499 let mut paths = Vec::new();
500 if let Some(path) = env::var_os(DEV_SECRETS_PATH_ENV) {
501 paths.push(PathBuf::from(path));
502 }
503 paths.push(bundle_root.join(".greentic/dev/.dev.secrets.env"));
504 paths.push(bundle_root.join(".greentic/state/dev/.dev.secrets.env"));
505
506 let mut seen = BTreeSet::new();
507 paths
508 .into_iter()
509 .filter(|path| seen.insert(path.clone()))
510 .collect()
511}
512
513fn discover_bundle_pack_paths(bundle_root: &Path) -> Result<Vec<PathBuf>> {
514 let mut out = Vec::new();
515 collect_pack_paths_from_dir(&bundle_root.join("packs"), &mut out)?;
516 collect_pack_paths_from_dir(&bundle_root.join("providers"), &mut out)?;
517 out.sort();
518 Ok(out)
519}
520
521fn collect_pack_paths_from_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
522 if !dir.exists() {
523 return Ok(());
524 }
525 for entry in std::fs::read_dir(dir)? {
526 let entry = entry?;
527 let path = entry.path();
528 if path.extension().and_then(|value| value.to_str()) == Some("gtpack") {
529 out.push(path);
530 continue;
531 }
532 if path.is_dir() {
533 if path.join("pack.yaml").exists() || path.join("manifest.cbor").exists() {
534 out.push(path);
535 } else {
536 collect_pack_paths_from_dir(&path, out)?;
537 }
538 }
539 }
540 Ok(())
541}
542
543fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
544 let mut seen = BTreeSet::new();
545 paths
546 .into_iter()
547 .filter(|path| seen.insert(path.clone()))
548 .collect()
549}
550
551fn provider_id_from_pack_path(pack_path: &Path) -> String {
552 pack_path
553 .file_stem()
554 .and_then(|value| value.to_str())
555 .map(str::trim)
556 .filter(|value| !value.is_empty())
557 .map(ToOwned::to_owned)
558 .unwrap_or_else(|| "provider".to_string())
559}
560
561fn config_id_from_pack_path(pack_path: &Path) -> Option<String> {
562 pack_path
563 .file_stem()
564 .and_then(|value| value.to_str())
565 .map(ToOwned::to_owned)
566}
567
568fn infer_bundle_root_from_pack_path(pack_path: &Path) -> Option<PathBuf> {
569 let mut current = if pack_path.is_dir() {
570 Some(pack_path)
571 } else {
572 pack_path.parent()
573 };
574 while let Some(path) = current {
575 if path.file_name().and_then(|value| value.to_str()) == Some("packs") {
576 return path.parent().map(Path::to_path_buf);
577 }
578 if path.join("bundle.yaml").exists() {
579 return Some(path.to_path_buf());
580 }
581 current = path.parent();
582 }
583 None
584}
585
586fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
587 if pack_path.is_dir() {
588 return load_secret_requirements_from_dir(pack_path);
589 }
590 if !is_probably_zip(pack_path)? {
591 return load_secret_requirements_from_tar(pack_path);
592 }
593 load_secret_requirements_from_zip(pack_path)
594}
595
596fn is_probably_zip(path: &Path) -> Result<bool> {
597 let mut file = File::open(path)?;
598 let mut magic = [0_u8; 4];
599 let read = file.read(&mut magic)?;
600 Ok(read == magic.len() && magic == [0x50, 0x4b, 0x03, 0x04])
601}
602
603fn is_probably_tar(path: &Path) -> Result<bool> {
604 let mut file = File::open(path)?;
605 file.seek(SeekFrom::Start(257))?;
606 let mut magic = [0_u8; 5];
607 let read = file.read(&mut magic)?;
608 Ok(read == magic.len() && magic == *b"ustar")
609}
610
611fn load_secret_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
612 let mut requirements = load_generated_requirements_from_dir(pack_path)?;
613 for asset in SECRET_ASSET_PATHS {
614 let path = pack_path.join(asset);
615 if path.exists() {
616 let contents = std::fs::read_to_string(&path)?;
617 requirements.extend(parse_requirements(&contents, &path)?);
618 }
619 }
620 let setup_yaml = pack_path.join("assets/setup.yaml");
621 if setup_yaml.exists() {
622 let contents = std::fs::read_to_string(&setup_yaml)?;
623 requirements.extend(parse_setup_secret_requirements(&contents, &setup_yaml)?);
624 }
625 Ok(dedup_requirements(requirements))
626}
627
628fn load_secret_requirements_from_zip(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
629 let file = File::open(pack_path)?;
630 let mut archive = match ZipArchive::new(file) {
631 Ok(archive) => archive,
632 Err(_) => return Ok(Vec::new()),
633 };
634 let mut requirements = load_generated_requirements_from_zip(&mut archive)?;
635 for asset in SECRET_ASSET_PATHS {
636 match archive.by_name(asset) {
637 Ok(mut entry) => {
638 let mut contents = String::new();
639 entry.read_to_string(&mut contents)?;
640 requirements.extend(parse_requirements(&contents, Path::new(asset))?);
641 }
642 Err(ZipError::FileNotFound) => continue,
643 Err(err) => return Err(DeployerError::Other(err.to_string())),
644 }
645 }
646 if let Ok(mut entry) = archive.by_name("assets/setup.yaml") {
647 let mut contents = String::new();
648 entry.read_to_string(&mut contents)?;
649 requirements.extend(parse_setup_secret_requirements(
650 &contents,
651 Path::new("assets/setup.yaml"),
652 )?);
653 }
654 Ok(dedup_requirements(requirements))
655}
656
657fn load_secret_requirements_from_tar(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
658 if !is_probably_tar(pack_path)? {
659 return Ok(Vec::new());
660 }
661 let file = File::open(pack_path)?;
662 let mut archive = tar::Archive::new(file);
663 let entries = match archive.entries() {
664 Ok(entries) => entries,
665 Err(_) => return Ok(Vec::new()),
666 };
667 let mut requirements = Vec::new();
668 for entry in entries {
669 let mut entry = match entry {
670 Ok(entry) => entry,
671 Err(_) => continue,
672 };
673 let path = match entry.path() {
674 Ok(path) => path.into_owned(),
675 Err(_) => continue,
676 };
677 let Some(path_str) = path.to_str() else {
678 continue;
679 };
680 if SECRET_ASSET_PATHS.contains(&path_str) {
681 let mut contents = String::new();
682 entry.read_to_string(&mut contents)?;
683 requirements.extend(parse_requirements(&contents, &path)?);
684 } else if path_str == "assets/setup.yaml" {
685 let mut contents = String::new();
686 entry.read_to_string(&mut contents)?;
687 requirements.extend(parse_setup_secret_requirements(&contents, &path)?);
688 } else if path_str == "manifest.cbor" {
689 let mut bytes = Vec::new();
690 entry.read_to_end(&mut bytes)?;
691 requirements.extend(load_generated_requirements_from_manifest_cbor_bytes(
692 &bytes,
693 )?);
694 } else if path_str == "pack.manifest.json" {
695 let mut contents = String::new();
696 entry.read_to_string(&mut contents)?;
697 requirements.extend(load_generated_requirements_from_manifest_json_str(
698 &contents,
699 )?);
700 }
701 }
702 Ok(dedup_requirements(requirements))
703}
704
705fn parse_requirements(contents: &str, path: &Path) -> Result<Vec<PackSecretRequirement>> {
706 let path_display = path.display().to_string();
707 let requirements: Vec<AssetSecretRequirement> =
708 serde_json::from_str(contents).map_err(|err| {
709 DeployerError::Config(format!(
710 "parse secret requirements from {path_display}: {err}"
711 ))
712 })?;
713 Ok(requirements
714 .into_iter()
715 .filter_map(asset_requirement_to_pack_requirement)
716 .collect())
717}
718
719#[derive(Clone, Debug, PartialEq, Eq)]
720struct PackSecretRequirement {
721 key: String,
722 required: bool,
723 default_value: Option<String>,
724 generated: Option<GeneratedSecretRequirement>,
725}
726
727#[derive(Debug, Deserialize)]
728struct AssetSecretRequirement {
729 key: Option<String>,
730 name: Option<String>,
731 #[serde(default = "default_required")]
732 required: bool,
733 #[serde(default)]
734 default_value: Option<String>,
735 #[serde(default)]
736 generated: Option<AssetGeneratedSecret>,
737}
738
739#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
740struct AssetGeneratedSecret {
741 policy: Option<String>,
742 length: Option<usize>,
743 encoding: Option<String>,
744 scope: Option<AssetGeneratedSecretScope>,
745 #[serde(default)]
746 regenerate_if_present: Option<bool>,
747}
748
749#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
750struct AssetGeneratedSecretScope {
751 level: Option<String>,
752 team: Option<String>,
753}
754
755#[derive(Debug, Deserialize)]
756struct GeneratedSecretsExtension {
757 #[serde(default)]
758 secrets: Vec<GeneratedSecretEntry>,
759}
760
761#[derive(Debug, Deserialize)]
762struct GeneratedSecretEntry {
763 key: String,
764 #[serde(default = "default_required")]
765 required: bool,
766 policy: Option<String>,
767 length: Option<usize>,
768 encoding: Option<String>,
769 scope: Option<AssetGeneratedSecretScope>,
770 #[serde(default)]
771 regenerate_if_present: Option<bool>,
772}
773
774#[derive(Debug, Deserialize)]
775struct SetupSpec {
776 #[serde(default)]
777 questions: Vec<SetupQuestion>,
778}
779
780#[derive(Debug, Deserialize)]
781struct SetupQuestion {
782 name: String,
783 #[serde(default)]
784 secret_key: Option<String>,
785 #[serde(default)]
786 default: Option<String>,
787 #[serde(default)]
788 secret: bool,
789 #[serde(default)]
790 required: bool,
791}
792
793fn parse_setup_secret_requirements(
794 contents: &str,
795 path: &Path,
796) -> Result<Vec<PackSecretRequirement>> {
797 let path_display = path.display().to_string();
798 let setup: SetupSpec = serde_yaml_bw::from_str(contents).map_err(|err| {
799 DeployerError::Config(format!("parse setup secrets from {path_display}: {err}"))
800 })?;
801 Ok(setup
802 .questions
803 .into_iter()
804 .filter(|question| question.secret)
805 .map(|question| PackSecretRequirement {
806 key: question.secret_key.unwrap_or(question.name),
807 required: question.required,
808 default_value: question.default,
809 generated: None,
810 })
811 .collect())
812}
813
814fn dedup_requirements(requirements: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
815 let mut by_key = BTreeMap::new();
816 for requirement in requirements {
817 let key = canonical_secret_name(&requirement.key);
818 by_key
819 .entry(key)
820 .and_modify(|existing: &mut PackSecretRequirement| {
821 existing.required |= requirement.required;
822 if existing.default_value.is_none() {
823 existing.default_value = requirement.default_value.clone();
824 }
825 if existing.generated.is_none() {
826 existing.generated = requirement.generated.clone();
827 }
828 })
829 .or_insert(requirement);
830 }
831 by_key.into_values().collect()
832}
833
834fn asset_requirement_to_pack_requirement(
835 req: AssetSecretRequirement,
836) -> Option<PackSecretRequirement> {
837 let key = req.key.or(req.name)?;
838 Some(PackSecretRequirement {
839 key,
840 required: req.required,
841 default_value: req.default_value,
842 generated: req.generated.map(|generated| GeneratedSecretRequirement {
843 policy: generated.policy.unwrap_or_else(|| "random".to_string()),
844 length: generated.length.unwrap_or(32),
845 encoding: generated
846 .encoding
847 .unwrap_or_else(|| "base64url".to_string()),
848 scope: GeneratedSecretScope {
849 level: generated
850 .scope
851 .as_ref()
852 .and_then(|scope| scope.level.clone())
853 .unwrap_or_else(|| "team".to_string()),
854 team: generated.scope.and_then(|scope| scope.team),
855 },
856 regenerate_if_present: generated.regenerate_if_present.unwrap_or(false),
857 }),
858 })
859}
860
861fn load_generated_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
862 let manifest_cbor = pack_path.join("manifest.cbor");
863 if manifest_cbor.exists() {
864 let bytes = std::fs::read(&manifest_cbor)?;
865 let requirements = load_generated_requirements_from_manifest_cbor_bytes(&bytes)?;
866 if !requirements.is_empty() {
867 return Ok(requirements);
868 }
869 }
870 let manifest_json = pack_path.join("pack.manifest.json");
871 if manifest_json.exists() {
872 let contents = std::fs::read_to_string(&manifest_json)?;
873 return load_generated_requirements_from_manifest_json_str(&contents);
874 }
875 Ok(Vec::new())
876}
877
878fn load_generated_requirements_from_zip<R: Read + Seek>(
879 archive: &mut ZipArchive<R>,
880) -> Result<Vec<PackSecretRequirement>> {
881 match archive.by_name("manifest.cbor") {
882 Ok(mut entry) => {
883 let mut bytes = Vec::new();
884 entry.read_to_end(&mut bytes)?;
885 let requirements = load_generated_requirements_from_manifest_cbor_bytes(&bytes)?;
886 if !requirements.is_empty() {
887 return Ok(requirements);
888 }
889 }
890 Err(ZipError::FileNotFound) => {}
891 Err(err) => return Err(DeployerError::Other(err.to_string())),
892 }
893 match archive.by_name("pack.manifest.json") {
894 Ok(mut entry) => {
895 let mut contents = String::new();
896 entry.read_to_string(&mut contents)?;
897 load_generated_requirements_from_manifest_json_str(&contents)
898 }
899 Err(ZipError::FileNotFound) => Ok(Vec::new()),
900 Err(err) => Err(DeployerError::Other(err.to_string())),
901 }
902}
903
904fn load_generated_requirements_from_manifest_cbor_bytes(
905 bytes: &[u8],
906) -> Result<Vec<PackSecretRequirement>> {
907 if let Ok(manifest) = decode_pack_manifest(bytes) {
908 let Some(value) = manifest
909 .extensions
910 .as_ref()
911 .and_then(|extensions| extensions.get(EXT_GENERATED_SECRETS_V1))
912 .and_then(|extension| extension.inline.as_ref())
913 else {
914 return Ok(Vec::new());
915 };
916 let ExtensionInline::Other(value) = value else {
917 return Ok(Vec::new());
918 };
919 return parse_generated_secrets_extension(value.clone());
920 }
921
922 let Ok(value) = serde_cbor::from_slice::<CborValue>(bytes) else {
923 return Ok(Vec::new());
924 };
925 let Some(inline) = cbor_generated_extension_inline(&value) else {
926 return Ok(Vec::new());
927 };
928 let json = cbor_to_json(inline)?;
929 parse_generated_secrets_extension(json)
930}
931
932fn load_generated_requirements_from_manifest_json_str(
933 contents: &str,
934) -> Result<Vec<PackSecretRequirement>> {
935 let manifest: serde_json::Value = serde_json::from_str(contents).map_err(|err| {
936 DeployerError::Config(format!("parse pack.manifest.json generated secrets: {err}"))
937 })?;
938 let Some(value) = manifest
939 .get("extensions")
940 .and_then(|extensions| extensions.get(EXT_GENERATED_SECRETS_V1))
941 .and_then(|extension| extension.get("inline"))
942 else {
943 return Ok(Vec::new());
944 };
945 parse_generated_secrets_extension(value.clone())
946}
947
948fn parse_generated_secrets_extension(
949 value: serde_json::Value,
950) -> Result<Vec<PackSecretRequirement>> {
951 let extension: GeneratedSecretsExtension = serde_json::from_value(value).map_err(|err| {
952 DeployerError::Config(format!("parse generated secrets extension: {err}"))
953 })?;
954 Ok(extension
955 .secrets
956 .into_iter()
957 .filter(|secret| secret.required)
958 .map(|secret| PackSecretRequirement {
959 key: secret.key,
960 required: true,
961 default_value: None,
962 generated: Some(GeneratedSecretRequirement {
963 policy: secret.policy.unwrap_or_else(|| "random".to_string()),
964 length: secret.length.unwrap_or(20),
965 encoding: secret.encoding.unwrap_or_else(|| "raw_text".to_string()),
966 scope: GeneratedSecretScope {
967 level: secret
968 .scope
969 .as_ref()
970 .and_then(|scope| scope.level.clone())
971 .unwrap_or_else(|| "tenant".to_string()),
972 team: secret.scope.and_then(|scope| scope.team),
973 },
974 regenerate_if_present: secret.regenerate_if_present.unwrap_or(false),
975 }),
976 })
977 .collect())
978}
979
980fn cbor_generated_extension_inline(value: &CborValue) -> Option<&CborValue> {
981 let CborValue::Map(map) = value else {
982 return None;
983 };
984 let extensions = cbor_map_get(map, "extensions")?;
985 let CborValue::Map(extensions) = extensions else {
986 return None;
987 };
988 let extension = cbor_map_get(extensions, EXT_GENERATED_SECRETS_V1)?;
989 let CborValue::Map(extension) = extension else {
990 return None;
991 };
992 cbor_map_get(extension, "inline")
993}
994
995fn cbor_map_get<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
996 map.iter().find_map(|(candidate, value)| match candidate {
997 CborValue::Text(text) if text == key => Some(value),
998 _ => None,
999 })
1000}
1001
1002fn cbor_to_json(value: &CborValue) -> Result<serde_json::Value> {
1003 match value {
1004 CborValue::Null => Ok(serde_json::Value::Null),
1005 CborValue::Bool(value) => Ok(serde_json::Value::Bool(*value)),
1006 CborValue::Integer(value) => Ok(serde_json::Value::Number(
1007 serde_json::Number::from_i128(*value).ok_or_else(|| {
1008 DeployerError::Config("generated secrets integer is out of range".to_string())
1009 })?,
1010 )),
1011 CborValue::Float(value) => serde_json::Number::from_f64(*value)
1012 .map(serde_json::Value::Number)
1013 .ok_or_else(|| DeployerError::Config("generated secrets float is invalid".to_string())),
1014 CborValue::Bytes(_) => Err(DeployerError::Config(
1015 "generated secrets extension cannot contain bytes".to_string(),
1016 )),
1017 CborValue::Text(value) => Ok(serde_json::Value::String(value.clone())),
1018 CborValue::Array(values) => values
1019 .iter()
1020 .map(cbor_to_json)
1021 .collect::<Result<Vec<_>>>()
1022 .map(serde_json::Value::Array),
1023 CborValue::Map(map) => {
1024 let mut object = serde_json::Map::new();
1025 for (key, value) in map {
1026 let CborValue::Text(key) = key else {
1027 return Err(DeployerError::Config(
1028 "generated secrets extension object key must be a string".to_string(),
1029 ));
1030 };
1031 object.insert(key.clone(), cbor_to_json(value)?);
1032 }
1033 Ok(serde_json::Value::Object(object))
1034 }
1035 _ => Err(DeployerError::Config(
1036 "generated secrets extension contains unsupported CBOR value".to_string(),
1037 )),
1038 }
1039}
1040
1041fn generated_secret_value(generated: &GeneratedSecretRequirement) -> Result<String> {
1042 if !generated.policy.eq_ignore_ascii_case("random") {
1043 return Err(DeployerError::Config(format!(
1044 "unsupported generated secret policy `{}`",
1045 generated.policy
1046 )));
1047 }
1048 let length = generated.length.max(1);
1049 match generated.encoding.as_str() {
1050 "raw_text" => Ok(random_ascii(length)),
1051 "base64url" => {
1052 let mut bytes = vec![0u8; length];
1053 rand::rng().fill(&mut bytes[..]);
1054 Ok(URL_SAFE_NO_PAD.encode(bytes))
1055 }
1056 "hex" => {
1057 let mut bytes = vec![0u8; length];
1058 rand::rng().fill(&mut bytes[..]);
1059 Ok(hex::encode(bytes))
1060 }
1061 other => Err(DeployerError::Config(format!(
1062 "unsupported generated secret encoding `{other}`"
1063 ))),
1064 }
1065}
1066
1067fn random_ascii(length: usize) -> String {
1068 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
1069 let mut bytes = vec![0u8; length];
1070 rand::rng().fill(&mut bytes[..]);
1071 bytes
1072 .into_iter()
1073 .map(|byte| ALPHABET[usize::from(byte) % ALPHABET.len()] as char)
1074 .collect()
1075}
1076
1077fn resolve_from_setup_answers(
1078 bundle_root: &Path,
1079 requirement: &RuntimeSecretRequirement,
1080 checked_sources: &mut Vec<String>,
1081) -> Option<(PathBuf, String)> {
1082 for config_id in setup_answer_config_id_candidates(&requirement.source) {
1083 let path = bundle_root
1084 .join("state/config")
1085 .join(config_id)
1086 .join("setup-answers.json");
1087 checked_sources.push(path.display().to_string());
1088 let contents = match std::fs::read_to_string(&path) {
1089 Ok(contents) => contents,
1090 Err(_) => continue,
1091 };
1092 let answers = match serde_json::from_str::<BTreeMap<String, JsonValue>>(&contents) {
1093 Ok(answers) => answers,
1094 Err(_) => continue,
1095 };
1096 for (key, value) in answers {
1097 if canonical_secret_name(&key) != requirement.key {
1098 continue;
1099 }
1100 if let Some(value) = value.as_str()
1101 && !value.is_empty()
1102 {
1103 if let Some(env_key) = extract_env_placeholder(value) {
1104 checked_sources.push(format!("env ${{{env_key}}} (from setup-answers)"));
1105 return match env::var(&env_key) {
1106 Ok(resolved) if !resolved.is_empty() => Some((path, resolved)),
1107 _ => None,
1108 };
1109 }
1110 return Some((path, value.to_string()));
1111 }
1112 }
1113 }
1114 None
1115}
1116
1117fn setup_answer_config_id_candidates(pack_path: &Path) -> Vec<String> {
1118 let Some(config_id) = config_id_from_pack_path(pack_path) else {
1119 return Vec::new();
1120 };
1121 let mut candidates = vec![config_id.clone()];
1122 if let Some((base, _)) = config_id.split_once("-gtpack-sha-")
1123 && !base.is_empty()
1124 {
1125 candidates.push(base.to_string());
1126 }
1127 candidates
1128}
1129
1130fn extract_env_placeholder(value: &str) -> Option<String> {
1137 let trimmed = value.trim();
1138 let inner = trimmed.strip_prefix("${")?.strip_suffix('}')?;
1139 if inner.is_empty() || inner.contains(|c: char| c.is_whitespace() || c == '$' || c == '{') {
1140 return None;
1141 }
1142 Some(inner.to_string())
1143}
1144
1145fn default_required() -> bool {
1146 true
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152
1153 #[test]
1154 fn extract_env_placeholder_matches_whole_string_dollar_brace_form() {
1155 assert_eq!(
1156 extract_env_placeholder("${REDIS_URL}").as_deref(),
1157 Some("REDIS_URL")
1158 );
1159 assert_eq!(
1160 extract_env_placeholder("${OPENAI_API_KEY}").as_deref(),
1161 Some("OPENAI_API_KEY")
1162 );
1163 assert_eq!(
1164 extract_env_placeholder(" ${PUBLIC_BASE_URL} ").as_deref(),
1165 Some("PUBLIC_BASE_URL"),
1166 "surrounding whitespace is allowed"
1167 );
1168 }
1169
1170 #[test]
1171 fn extract_env_placeholder_rejects_partial_or_malformed_patterns() {
1172 assert_eq!(extract_env_placeholder("redis://host:6379/0"), None);
1173 assert_eq!(extract_env_placeholder("prefix-${VAR}"), None);
1174 assert_eq!(extract_env_placeholder("${VAR}-suffix"), None);
1175 assert_eq!(extract_env_placeholder("${}"), None);
1176 assert_eq!(extract_env_placeholder("${VAR WITH SPACE}"), None);
1177 assert_eq!(extract_env_placeholder("${NESTED${INNER}}"), None);
1178 }
1179
1180 #[test]
1181 fn setup_answer_config_id_candidates_include_gtpack_sha_base_alias() {
1182 let candidates = setup_answer_config_id_candidates(Path::new(
1183 "/tmp/packs/deep-research-demo-gtpack-sha-abc123.gtpack",
1184 ));
1185 assert_eq!(
1186 candidates,
1187 vec![
1188 "deep-research-demo-gtpack-sha-abc123".to_string(),
1189 "deep-research-demo".to_string(),
1190 ]
1191 );
1192 }
1193
1194 #[test]
1195 fn canonical_env_key_matches_start_runtime_shape() {
1196 assert_eq!(
1197 canonical_secret_store_key("secrets://dev/demo/_/openai/api_key").as_deref(),
1198 Some("GREENTIC_SECRET__DEV__DEMO_____OPENAI__API_KEY")
1199 );
1200 }
1201
1202 #[test]
1203 fn cloud_secret_name_is_stable_and_normalized() {
1204 assert_eq!(
1205 cloud_secret_name(
1206 "greentic/dev/demo/_",
1207 "messaging-telegram",
1208 "TELEGRAM_BOT_TOKEN"
1209 ),
1210 "greentic/dev/demo/_/messaging_telegram/telegram_bot_token"
1211 );
1212 }
1213
1214 #[test]
1215 fn requirement_uri_normalizes_provider_segment() {
1216 let dir = tempfile::tempdir().unwrap();
1217 let pack_dir = dir.path().join("packs/messaging-webchat-gui/assets");
1218 std::fs::create_dir_all(&pack_dir).unwrap();
1219 std::fs::write(
1220 pack_dir.join("secret-requirements.json"),
1221 r#"[{"key":"jwt_signing_key","required":true}]"#,
1222 )
1223 .unwrap();
1224
1225 let ctx = RuntimeSecretContext {
1226 bundle_root: dir.path().to_path_buf(),
1227 pack_paths: vec![dir.path().join("packs/messaging-webchat-gui")],
1228 environment: "dev".into(),
1229 tenant: "demo".into(),
1230 team: None,
1231 };
1232
1233 let requirements = collect_requirements(&ctx).unwrap();
1234 assert_eq!(requirements.len(), 1);
1235 assert_eq!(requirements[0].provider_id, "messaging-webchat-gui");
1236 assert_eq!(
1237 requirements[0].uri,
1238 "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1239 );
1240 assert_eq!(
1241 cloud_secret_name(
1242 "greentic/dev/demo/_",
1243 &requirements[0].provider_id,
1244 &requirements[0].key
1245 ),
1246 "greentic/dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1247 );
1248 }
1249
1250 #[test]
1251 fn collect_requirements_discovers_generated_secret_from_manifest_cbor_extension() {
1252 use greentic_types::{
1253 ExtensionInline, ExtensionRef, PackId, PackKind, PackManifest, PackSignatures,
1254 encode_pack_manifest,
1255 };
1256 use semver::Version;
1257 use serde_json::json;
1258 use std::io::Write;
1259 use zip::write::FileOptions;
1260
1261 let dir = tempfile::tempdir().unwrap();
1262 let pack = dir.path().join("packs/messaging-webchat-gui.gtpack");
1263 std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
1264 let mut extensions = BTreeMap::new();
1265 extensions.insert(
1266 "greentic.generated-secrets.v1".to_string(),
1267 ExtensionRef {
1268 kind: "greentic.generated-secrets.v1".to_string(),
1269 version: "1".to_string(),
1270 digest: None,
1271 location: None,
1272 inline: Some(ExtensionInline::Other(json!({
1273 "secrets": [{
1274 "key": "jwt_signing_key",
1275 "aliases": ["JWT_SIGNING_KEY"],
1276 "required": true,
1277 "policy": "random",
1278 "length": 20,
1279 "encoding": "raw_text",
1280 "scope": {"level": "tenant", "team": "_"},
1281 "regenerate_if_present": false
1282 }]
1283 }))),
1284 },
1285 );
1286 let manifest = PackManifest {
1287 schema_version: "1".to_string(),
1288 pack_id: PackId::new("messaging-webchat-gui").unwrap(),
1289 name: None,
1290 version: Version::parse("0.0.0").unwrap(),
1291 kind: PackKind::Provider,
1292 publisher: "test".to_string(),
1293 components: Vec::new(),
1294 flows: Vec::new(),
1295 dependencies: Vec::new(),
1296 capabilities: Vec::new(),
1297 secret_requirements: Vec::new(),
1298 signatures: PackSignatures::default(),
1299 bootstrap: None,
1300 extensions: Some(extensions),
1301 };
1302 let file = File::create(&pack).unwrap();
1303 let mut zip = zip::ZipWriter::new(file);
1304 zip.start_file("manifest.cbor", FileOptions::<()>::default())
1305 .unwrap();
1306 zip.write_all(&encode_pack_manifest(&manifest).unwrap())
1307 .unwrap();
1308 zip.finish().unwrap();
1309
1310 let ctx = RuntimeSecretContext {
1311 bundle_root: dir.path().to_path_buf(),
1312 pack_paths: vec![pack],
1313 environment: "dev".into(),
1314 tenant: "demo".into(),
1315 team: Some("default".into()),
1316 };
1317
1318 let requirements = collect_requirements(&ctx).unwrap();
1319 assert_eq!(requirements.len(), 1);
1320 assert_eq!(
1321 requirements[0].uri,
1322 "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1323 );
1324 assert_eq!(requirements[0].key, "jwt_signing_key");
1325 assert!(requirements[0].generated.is_some());
1326 }
1327
1328 #[test]
1329 fn collect_requirements_discovers_generated_secret_from_pack_manifest_json_extension() {
1330 use std::io::Write;
1331 use zip::write::FileOptions;
1332
1333 let dir = tempfile::tempdir().unwrap();
1334 let pack = dir.path().join("packs/messaging-webchat-gui.gtpack");
1335 std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
1336 let file = File::create(&pack).unwrap();
1337 let mut zip = zip::ZipWriter::new(file);
1338 zip.start_file("pack.manifest.json", FileOptions::<()>::default())
1339 .unwrap();
1340 zip.write_all(
1341 br#"{
1342 "extensions": {
1343 "greentic.generated-secrets.v1": {
1344 "inline": {
1345 "secrets": [{
1346 "key": "jwt_signing_key",
1347 "policy": "random",
1348 "length": 20,
1349 "encoding": "raw_text",
1350 "scope": {"level": "tenant", "team": "_"}
1351 }]
1352 }
1353 }
1354 }
1355 }"#,
1356 )
1357 .unwrap();
1358 zip.finish().unwrap();
1359
1360 let ctx = RuntimeSecretContext {
1361 bundle_root: dir.path().to_path_buf(),
1362 pack_paths: vec![pack],
1363 environment: "dev".into(),
1364 tenant: "demo".into(),
1365 team: Some("default".into()),
1366 };
1367
1368 let requirements = collect_requirements(&ctx).unwrap();
1369 assert_eq!(requirements.len(), 1);
1370 assert_eq!(
1371 requirements[0].uri,
1372 "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1373 );
1374 assert!(requirements[0].generated.is_some());
1375 }
1376
1377 #[test]
1378 fn runtime_secret_env_map_omits_generated_secret_env_aliases_for_cloud_binding() {
1379 use greentic_types::{
1380 ExtensionInline, ExtensionRef, PackId, PackKind, PackManifest, PackSignatures,
1381 encode_pack_manifest,
1382 };
1383 use semver::Version;
1384 use serde_json::json;
1385 use std::io::Write;
1386 use zip::write::FileOptions;
1387
1388 let dir = tempfile::tempdir().unwrap();
1389 let bundle_root = dir.path();
1390 let pack = bundle_root.join("packs/messaging-webchat-gui.gtpack");
1391 std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
1392 let mut extensions = BTreeMap::new();
1393 extensions.insert(
1394 "greentic.generated-secrets.v1".to_string(),
1395 ExtensionRef {
1396 kind: "greentic.generated-secrets.v1".to_string(),
1397 version: "1".to_string(),
1398 digest: None,
1399 location: None,
1400 inline: Some(ExtensionInline::Other(json!({
1401 "secrets": [{
1402 "key": "jwt_signing_key",
1403 "policy": "random",
1404 "length": 20,
1405 "encoding": "raw_text",
1406 "scope": {"level": "tenant", "team": "_"}
1407 }]
1408 }))),
1409 },
1410 );
1411 let manifest = PackManifest {
1412 schema_version: "1".to_string(),
1413 pack_id: PackId::new("messaging-webchat-gui").unwrap(),
1414 name: None,
1415 version: Version::parse("0.0.0").unwrap(),
1416 kind: PackKind::Provider,
1417 publisher: "test".to_string(),
1418 components: Vec::new(),
1419 flows: Vec::new(),
1420 dependencies: Vec::new(),
1421 capabilities: Vec::new(),
1422 secret_requirements: Vec::new(),
1423 signatures: PackSignatures::default(),
1424 bootstrap: None,
1425 extensions: Some(extensions),
1426 };
1427 let file = File::create(&pack).unwrap();
1428 let mut zip = zip::ZipWriter::new(file);
1429 zip.start_file("manifest.cbor", FileOptions::<()>::default())
1430 .unwrap();
1431 zip.write_all(&encode_pack_manifest(&manifest).unwrap())
1432 .unwrap();
1433 zip.finish().unwrap();
1434
1435 let config = DeployerConfig {
1436 capability: DeployerCapability::Apply,
1437 provider: Provider::Aws,
1438 strategy: "iac-only".into(),
1439 tenant: "demo".into(),
1440 environment: "dev".into(),
1441 pack_path: pack.clone(),
1442 bundle_root: Some(bundle_root.to_path_buf()),
1443 providers_dir: PathBuf::from("providers/deployer"),
1444 packs_dir: PathBuf::from("packs"),
1445 provider_pack: None,
1446 pack_ref: None,
1447 distributor_url: None,
1448 distributor_token: None,
1449 preview: false,
1450 dry_run: false,
1451 execute_local: true,
1452 output: crate::config::OutputFormat::Json,
1453 greentic: greentic_config::ConfigResolver::new()
1454 .load()
1455 .unwrap()
1456 .config,
1457 provenance: greentic_config::ProvenanceMap::new(),
1458 config_warnings: Vec::new(),
1459 deploy_pack_id_override: None,
1460 deploy_flow_id_override: None,
1461 bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1462 bundle_digest: Some(
1463 "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1464 ),
1465 repo_registry_base: None,
1466 store_registry_base: None,
1467 };
1468
1469 let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1470 assert!(env_map.is_empty());
1471 }
1472
1473 #[tokio::test]
1474 async fn cloud_apply_resolution_generates_generated_secret_without_setup_answer() {
1475 let dir = tempfile::tempdir().unwrap();
1476 let pack = dir.path().join("packs/messaging-webchat-gui");
1477 std::fs::create_dir_all(pack.join("assets")).unwrap();
1478 std::fs::write(
1479 pack.join("assets/secret-requirements.json"),
1480 r#"[{
1481 "key":"jwt_signing_key",
1482 "required":true,
1483 "generated":{
1484 "policy":"random",
1485 "length":20,
1486 "encoding":"raw_text",
1487 "scope":{"level":"tenant","team":"_"},
1488 "regenerate_if_present":false
1489 }
1490 }]"#,
1491 )
1492 .unwrap();
1493
1494 let ctx = RuntimeSecretContext {
1495 bundle_root: dir.path().to_path_buf(),
1496 pack_paths: vec![pack],
1497 environment: "dev".into(),
1498 tenant: "demo".into(),
1499 team: Some("default".into()),
1500 };
1501 let requirements = collect_requirements(&ctx).unwrap();
1502 let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
1503
1504 assert!(resolution.missing.is_empty());
1505 assert_eq!(resolution.resolved.len(), 1);
1506 assert_eq!(resolution.resolved[0].value.expose().len(), 20);
1507 assert!(matches!(
1508 resolution.resolved[0].source,
1509 SecretValueSource::Generated
1510 ));
1511 }
1512
1513 #[tokio::test]
1514 async fn cloud_apply_resolution_slides_optional_secret_without_local_value() {
1515 let dir = tempfile::tempdir().unwrap();
1520 let pack = dir.path().join("packs/deep-research-demo");
1521 std::fs::create_dir_all(pack.join("assets")).unwrap();
1522 std::fs::write(
1523 pack.join("assets/setup.yaml"),
1524 r#"
1525questions:
1526 - name: api_key_secret
1527 secret_key: api_key_secret
1528 secret: true
1529 required: false
1530 default: ollama-placeholder
1531"#,
1532 )
1533 .unwrap();
1534
1535 let ctx = RuntimeSecretContext {
1536 bundle_root: dir.path().to_path_buf(),
1537 pack_paths: vec![pack],
1538 environment: "dev".into(),
1539 tenant: "demo".into(),
1540 team: None,
1541 };
1542 let requirements = collect_requirements(&ctx).unwrap();
1543 let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
1544
1545 assert!(
1546 resolution.resolved.is_empty(),
1547 "optional secret must not resolve from a setup.yaml default: {:?}",
1548 resolution.resolved
1549 );
1550 assert!(
1551 resolution.missing.is_empty(),
1552 "optional secret must not be reported missing"
1553 );
1554 }
1555
1556 #[tokio::test]
1557 async fn cloud_apply_resolution_moves_generated_secret_from_local_store() {
1558 use greentic_secrets_lib::{DevStore, SecretFormat};
1562 let dir = tempfile::tempdir().unwrap();
1563 let pack = dir.path().join("packs/messaging-webchat-gui");
1564 std::fs::create_dir_all(pack.join("assets")).unwrap();
1565 std::fs::write(
1566 pack.join("assets/secret-requirements.json"),
1567 r#"[{
1568 "key":"jwt_signing_key",
1569 "required":true,
1570 "generated":{
1571 "policy":"random",
1572 "length":20,
1573 "encoding":"raw_text",
1574 "scope":{"level":"tenant","team":"_"},
1575 "regenerate_if_present":false
1576 }
1577 }]"#,
1578 )
1579 .unwrap();
1580
1581 let uri = "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key";
1582 let store_path = dir.path().join(".greentic/state/dev/.dev.secrets.env");
1583 std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1584 let store = DevStore::with_path(&store_path).unwrap();
1585 store
1586 .put(uri, SecretFormat::Text, b"setup-introduced-key")
1587 .await
1588 .unwrap();
1589
1590 let ctx = RuntimeSecretContext {
1591 bundle_root: dir.path().to_path_buf(),
1592 pack_paths: vec![pack],
1593 environment: "dev".into(),
1594 tenant: "demo".into(),
1595 team: None,
1596 };
1597 let requirements = collect_requirements(&ctx).unwrap();
1598 let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
1599
1600 assert!(resolution.missing.is_empty());
1601 assert_eq!(resolution.resolved.len(), 1);
1602 assert_eq!(
1603 resolution.resolved[0].value.expose(),
1604 "setup-introduced-key",
1605 "the local-store value must be moved, not regenerated"
1606 );
1607 assert!(matches!(
1608 resolution.resolved[0].source,
1609 SecretValueSource::DevStore { .. }
1610 ));
1611 }
1612
1613 #[test]
1614 fn generated_secret_value_supports_start_encodings() {
1615 let raw = generated_secret_value(&GeneratedSecretRequirement {
1616 policy: "random".into(),
1617 length: 20,
1618 encoding: "raw_text".into(),
1619 scope: GeneratedSecretScope {
1620 level: "tenant".into(),
1621 team: Some("_".into()),
1622 },
1623 regenerate_if_present: false,
1624 })
1625 .unwrap();
1626 assert_eq!(raw.len(), 20);
1627
1628 let b64 = generated_secret_value(&GeneratedSecretRequirement {
1629 policy: "random".into(),
1630 length: 20,
1631 encoding: "base64url".into(),
1632 scope: GeneratedSecretScope {
1633 level: "tenant".into(),
1634 team: Some("_".into()),
1635 },
1636 regenerate_if_present: false,
1637 })
1638 .unwrap();
1639 assert!(!b64.contains('+'));
1640 assert!(!b64.contains('/'));
1641 assert!(!b64.contains('='));
1642
1643 let hex = generated_secret_value(&GeneratedSecretRequirement {
1644 policy: "random".into(),
1645 length: 20,
1646 encoding: "hex".into(),
1647 scope: GeneratedSecretScope {
1648 level: "tenant".into(),
1649 team: Some("_".into()),
1650 },
1651 regenerate_if_present: false,
1652 })
1653 .unwrap();
1654 assert_eq!(hex.len(), 40);
1655 assert!(hex.chars().all(|ch| ch.is_ascii_hexdigit()));
1656 }
1657
1658 #[test]
1659 fn flat_secret_name_limits_length_with_digest() {
1660 let name = flat_cloud_secret_name(
1661 "greentic/dev/demo/default",
1662 "very-long-provider-name",
1663 "THIS_IS_A_VERY_LONG_SECRET_NAME",
1664 40,
1665 );
1666 assert!(name.len() <= 40);
1667 assert!(name.starts_with("greentic-dev-demo-default"));
1668 }
1669
1670 #[test]
1671 fn infers_bundle_root_from_pack_path_under_packs_dir() {
1672 let path = Path::new("/tmp/demo-bundle/packs/app.gtpack");
1673 assert_eq!(
1674 infer_bundle_root_from_pack_path(path).as_deref(),
1675 Some(Path::new("/tmp/demo-bundle"))
1676 );
1677 }
1678
1679 #[test]
1680 fn skips_non_zip_gtpack_when_scanning_secret_requirements() {
1681 let dir = tempfile::tempdir().unwrap();
1682 let pack = dir.path().join("aws.gtpack");
1683 std::fs::write(&pack, b"not a zip").unwrap();
1684 let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1685 assert!(reqs.is_empty());
1686 }
1687
1688 #[test]
1689 fn reads_secret_requirements_from_tar_gtpack() {
1690 let dir = tempfile::tempdir().unwrap();
1691 let pack = dir.path().join("provider.gtpack");
1692 let file = File::create(&pack).unwrap();
1693 let mut builder = tar::Builder::new(file);
1694 let contents = br#"[{"key":"API_TOKEN","required":true}]"#;
1695 let mut header = tar::Header::new_gnu();
1696 header.set_path("assets/secret-requirements.json").unwrap();
1697 header.set_size(contents.len() as u64);
1698 header.set_cksum();
1699 builder
1700 .append(&header, contents.as_slice())
1701 .expect("append tar entry");
1702 builder.finish().unwrap();
1703
1704 let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1705 assert_eq!(reqs.len(), 1);
1706 assert_eq!(reqs[0].key, "API_TOKEN");
1707 }
1708
1709 #[test]
1710 fn reads_secret_requirements_from_setup_yaml() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let pack = dir.path().join("pack");
1713 std::fs::create_dir_all(pack.join("assets")).unwrap();
1714 std::fs::write(
1715 pack.join("assets/setup.yaml"),
1716 r#"
1717questions:
1718 - name: api_key
1719 secret: true
1720 required: true
1721 - name: display_name
1722 secret: false
1723"#,
1724 )
1725 .unwrap();
1726
1727 let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1728 assert_eq!(reqs.len(), 1);
1729 assert_eq!(reqs[0].key, "api_key");
1730 assert!(reqs[0].required);
1731 }
1732
1733 #[test]
1734 fn discovers_provider_pack_paths() {
1735 let dir = tempfile::tempdir().unwrap();
1736 std::fs::create_dir_all(dir.path().join("providers/messaging")).unwrap();
1737 std::fs::write(
1738 dir.path()
1739 .join("providers/messaging/messaging-webchat-gui.gtpack"),
1740 b"",
1741 )
1742 .unwrap();
1743
1744 let paths = discover_bundle_pack_paths(dir.path()).unwrap();
1745 assert_eq!(paths.len(), 1);
1746 assert!(paths[0].ends_with("messaging-webchat-gui.gtpack"));
1747 }
1748
1749 #[tokio::test]
1750 async fn cloud_apply_resolution_filters_secrets_provider_packs_by_target() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let bundle_root = dir.path();
1753 let app_pack = bundle_root.join("packs/greentic-main-website");
1754 std::fs::create_dir_all(&app_pack).unwrap();
1755 std::fs::write(app_pack.join("pack.yaml"), "id: greentic-main-website\n").unwrap();
1756
1757 let aws_provider = bundle_root.join("providers/secrets/aws-sm");
1758 std::fs::create_dir_all(aws_provider.join("assets")).unwrap();
1759 std::fs::write(
1760 aws_provider.join("pack.yaml"),
1761 "id: greentic.secrets.aws-sm\n",
1762 )
1763 .unwrap();
1764 std::fs::write(
1765 aws_provider.join("assets/secret-requirements.json"),
1766 r#"[{
1767 "key":"aws_runtime_probe",
1768 "required":true,
1769 "generated":{
1770 "policy":"random",
1771 "length":20,
1772 "encoding":"raw_text",
1773 "scope":{"level":"tenant","team":"_"}
1774 }
1775 }]"#,
1776 )
1777 .unwrap();
1778
1779 for (provider_dir, key) in [
1780 ("gcp-sm", "gcp_project_credentials"),
1781 ("azure-kv", "azure_key_vault_credentials"),
1782 ] {
1783 let inactive_provider = bundle_root.join("providers/secrets").join(provider_dir);
1784 std::fs::create_dir_all(inactive_provider.join("assets")).unwrap();
1785 std::fs::write(
1786 inactive_provider.join("pack.yaml"),
1787 format!("id: greentic.secrets.{provider_dir}\n"),
1788 )
1789 .unwrap();
1790 std::fs::write(
1791 inactive_provider.join("assets/secret-requirements.json"),
1792 format!(r#"[{{"key":"{key}","required":true}}]"#),
1793 )
1794 .unwrap();
1795 }
1796
1797 let config = DeployerConfig {
1798 capability: DeployerCapability::Apply,
1799 provider: Provider::Aws,
1800 strategy: "iac-only".into(),
1801 tenant: "demo".into(),
1802 environment: "dev".into(),
1803 pack_path: app_pack,
1804 bundle_root: Some(bundle_root.to_path_buf()),
1805 providers_dir: PathBuf::from("providers/deployer"),
1806 packs_dir: PathBuf::from("packs"),
1807 provider_pack: None,
1808 pack_ref: None,
1809 distributor_url: None,
1810 distributor_token: None,
1811 preview: false,
1812 dry_run: false,
1813 execute_local: true,
1814 output: crate::config::OutputFormat::Json,
1815 greentic: greentic_config::ConfigResolver::new()
1816 .load()
1817 .unwrap()
1818 .config,
1819 provenance: greentic_config::ProvenanceMap::new(),
1820 config_warnings: Vec::new(),
1821 deploy_pack_id_override: None,
1822 deploy_flow_id_override: None,
1823 bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1824 bundle_digest: Some(
1825 "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1826 ),
1827 repo_registry_base: None,
1828 store_registry_base: None,
1829 };
1830
1831 let resolution = resolve_for_cloud_apply(&config)
1832 .await
1833 .expect("resolve AWS cloud runtime secrets")
1834 .expect("runtime secrets should be present");
1835 let resolved_uris = resolution
1836 .resolved
1837 .iter()
1838 .map(|secret| secret.requirement.uri.as_str())
1839 .collect::<Vec<_>>();
1840
1841 assert_eq!(
1842 resolved_uris,
1843 vec!["secrets://dev/demo/_/aws_sm/aws_runtime_probe"]
1844 );
1845 assert!(resolution.missing.is_empty());
1846 }
1847
1848 #[tokio::test]
1849 async fn resolves_secret_values_from_setup_answers() {
1850 let dir = tempfile::tempdir().unwrap();
1851 let answers_dir = dir.path().join("state/config/demo-pack");
1852 std::fs::create_dir_all(&answers_dir).unwrap();
1853 std::fs::write(
1854 answers_dir.join("setup-answers.json"),
1855 r#"{"api_key":"secret-value"}"#,
1856 )
1857 .unwrap();
1858 let ctx = RuntimeSecretContext {
1859 bundle_root: dir.path().to_path_buf(),
1860 pack_paths: Vec::new(),
1861 environment: "dev".into(),
1862 tenant: "demo".into(),
1863 team: None,
1864 };
1865 let requirement = RuntimeSecretRequirement {
1866 uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "api_key"),
1867 provider_id: "demo_pack".into(),
1868 key: "api_key".into(),
1869 required: true,
1870 default_value: None,
1871 generated: None,
1872 source: dir.path().join("packs/demo-pack.gtpack"),
1873 };
1874
1875 let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1876 assert!(resolution.missing.is_empty());
1877 assert_eq!(resolution.resolved.len(), 1);
1878 assert_eq!(resolution.resolved[0].value.expose(), "secret-value");
1879 assert!(matches!(
1880 resolution.resolved[0].source,
1881 SecretValueSource::SetupAnswers { .. }
1882 ));
1883 }
1884
1885 #[test]
1886 fn runtime_secret_env_map_omits_explicit_secret_env_aliases_for_cloud_binding() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let bundle_root = dir.path();
1889 let packs_dir = bundle_root.join("packs");
1890 let config_dir = bundle_root.join("state/config/demo-app");
1891 std::fs::create_dir_all(packs_dir.join("demo-app/assets")).unwrap();
1892 std::fs::create_dir_all(&config_dir).unwrap();
1893 std::fs::write(
1894 packs_dir.join("demo-app/assets/setup.yaml"),
1895 r#"
1896questions:
1897 - name: api_key
1898 secret: true
1899 required: false
1900 - name: oauth_client_secret
1901 secret: true
1902 required: false
1903 - name: jwt_signing_key
1904 secret: true
1905 required: true
1906"#,
1907 )
1908 .unwrap();
1909 std::fs::write(
1910 config_dir.join("setup-answers.json"),
1911 r#"{"api_key":"secret-value"}"#,
1912 )
1913 .unwrap();
1914
1915 let config = DeployerConfig {
1916 capability: DeployerCapability::Apply,
1917 provider: Provider::Aws,
1918 strategy: "iac-only".into(),
1919 tenant: "demo".into(),
1920 environment: "dev".into(),
1921 pack_path: packs_dir.join("demo-app"),
1922 bundle_root: Some(bundle_root.to_path_buf()),
1923 providers_dir: PathBuf::from("providers/deployer"),
1924 packs_dir: PathBuf::from("packs"),
1925 provider_pack: None,
1926 pack_ref: None,
1927 distributor_url: None,
1928 distributor_token: None,
1929 preview: false,
1930 dry_run: false,
1931 execute_local: true,
1932 output: crate::config::OutputFormat::Json,
1933 greentic: greentic_config::ConfigResolver::new()
1934 .load()
1935 .unwrap()
1936 .config,
1937 provenance: greentic_config::ProvenanceMap::new(),
1938 config_warnings: Vec::new(),
1939 deploy_pack_id_override: None,
1940 deploy_flow_id_override: None,
1941 bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1942 bundle_digest: Some(
1943 "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1944 ),
1945 repo_registry_base: None,
1946 store_registry_base: None,
1947 };
1948
1949 let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1950 assert!(env_map.is_empty());
1951 }
1952
1953 #[test]
1954 fn runtime_secret_env_map_omits_optional_setup_secret_default_for_cloud_binding() {
1955 let dir = tempfile::tempdir().unwrap();
1956 let bundle_root = dir.path();
1957 let packs_dir = bundle_root.join("packs");
1958 std::fs::create_dir_all(packs_dir.join("deep-research-demo/assets")).unwrap();
1959 std::fs::write(
1960 packs_dir.join("deep-research-demo/assets/setup.yaml"),
1961 r#"
1962questions:
1963 - name: api_key_secret
1964 secret_key: api_key_secret
1965 secret: true
1966 required: false
1967 default: ollama-placeholder
1968"#,
1969 )
1970 .unwrap();
1971
1972 let config = DeployerConfig {
1973 capability: DeployerCapability::Apply,
1974 provider: Provider::Aws,
1975 strategy: "iac-only".into(),
1976 tenant: "demo".into(),
1977 environment: "dev".into(),
1978 pack_path: packs_dir.join("deep-research-demo"),
1979 bundle_root: Some(bundle_root.to_path_buf()),
1980 providers_dir: PathBuf::from("providers/deployer"),
1981 packs_dir: PathBuf::from("packs"),
1982 provider_pack: None,
1983 pack_ref: None,
1984 distributor_url: None,
1985 distributor_token: None,
1986 preview: false,
1987 dry_run: false,
1988 execute_local: true,
1989 output: crate::config::OutputFormat::Json,
1990 greentic: greentic_config::ConfigResolver::new()
1991 .load()
1992 .unwrap()
1993 .config,
1994 provenance: greentic_config::ProvenanceMap::new(),
1995 config_warnings: Vec::new(),
1996 deploy_pack_id_override: None,
1997 deploy_flow_id_override: None,
1998 bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1999 bundle_digest: Some(
2000 "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
2001 ),
2002 repo_registry_base: None,
2003 store_registry_base: None,
2004 };
2005
2006 let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
2007 assert!(env_map.is_empty());
2008 }
2009
2010 #[test]
2011 fn secret_value_debug_is_redacted() {
2012 let value = SecretValue("super-secret".to_string());
2013 assert_eq!(format!("{value:?}"), "<redacted>");
2014 }
2015}