1use std::path::{Path, PathBuf};
47
48use greentic_deploy_spec::EnvId;
49use serde::{Deserialize, Serialize};
50use serde_json::{Value, json};
51use thiserror::Error;
52use zeroize::Zeroizing;
53
54use crate::credentials::{
55 DeployerCredentials, RunBootstrapError, ValidateError, ZeroizedAdmin, run_bootstrap,
56 validate_requirements,
57};
58use crate::env_packs::EnvPackRegistry;
59use crate::env_packs::k8s::K8sDeployerCredentials;
60use crate::environment::{EnvironmentStore, LocalFsStore};
61
62use super::{AuditCtx, AuditGens, OpError, OpFlags, OpOutcome, audit_and_record};
63
64const NOUN: &str = "credentials";
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CredentialsRequirementsPayload {
68 pub environment_id: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CredentialsBootstrapPayload {
73 pub environment_id: String,
74 pub admin_profile: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub admin_material_path: Option<PathBuf>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub admin_material_inline: Option<String>,
88 #[serde(default)]
95 pub bind: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CredentialsRotatePayload {
100 pub environment_id: String,
101 pub admin_profile: String,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub admin_material_path: Option<PathBuf>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub admin_material_inline: Option<String>,
115 #[serde(default)]
121 pub if_needed: bool,
122}
123
124#[derive(Debug, Error)]
125enum AdminLoadError {
126 #[error("no admin material supplied: provide `admin_material_path` or `admin_material_inline`")]
127 Missing,
128 #[error("cannot supply both `admin_material_path` and `admin_material_inline`")]
129 Both,
130 #[error("read admin material from `{path}`: {source}")]
131 Io {
132 path: PathBuf,
133 #[source]
134 source: std::io::Error,
135 },
136 #[error("admin material at `{path}` is not valid UTF-8")]
137 NonUtf8 { path: PathBuf },
138 #[error("admin material is empty")]
139 Empty,
140}
141
142pub fn requirements(
146 store: &LocalFsStore,
147 registry: &EnvPackRegistry,
148 flags: &OpFlags,
149 payload: Option<CredentialsRequirementsPayload>,
150) -> Result<OpOutcome, OpError> {
151 if flags.schema_only {
152 return Ok(OpOutcome::new(NOUN, "requirements", req_schema()));
153 }
154 let payload = resolve_payload::<CredentialsRequirementsPayload>(flags, payload)?;
155 let env_id = parse_env_id(&payload.environment_id)?;
156
157 let connected = connected_k8s_credentials(store, &env_id)?;
162 let (doc, report) = validate_requirements(
163 store,
164 registry,
165 &env_id,
166 connected.as_ref().map(|c| c as &dyn DeployerCredentials),
167 )
168 .map_err(map_validate_err)?;
169
170 Ok(OpOutcome::new(
171 NOUN,
172 "requirements",
173 json!({
174 "environment_id": env_id.as_str(),
175 "deployer_kind": doc.deployer_kind.as_str(),
176 "credentials_ref": doc.provided_credentials_ref.as_str(),
177 "mode": "requirements",
178 "result": result_label(&doc.validation.result),
179 "missing_capabilities": doc.validation.missing_capabilities,
180 "checks": report.checks,
181 "last_run_at": doc.validation.last_run_at,
182 }),
183 ))
184}
185
186pub fn bootstrap(
187 store: &LocalFsStore,
188 registry: &EnvPackRegistry,
189 flags: &OpFlags,
190 payload: Option<CredentialsBootstrapPayload>,
191) -> Result<OpOutcome, OpError> {
192 if flags.schema_only {
193 return Ok(OpOutcome::new(NOUN, "bootstrap", bootstrap_schema()));
194 }
195 let mut payload = resolve_payload::<CredentialsBootstrapPayload>(flags, payload)?;
196 let env_id = parse_env_id(&payload.environment_id)?;
197 let bind_requested = payload.bind;
198
199 let admin = load_admin_credential(
200 &payload.admin_profile,
201 payload.admin_material_path.as_deref(),
202 &mut payload.admin_material_inline,
203 )
204 .map_err(|e| {
205 OpError::Conflict(e.to_string())
209 })?;
210
211 let bind_creds: Option<Box<dyn DeployerCredentials>> = if bind_requested {
218 let creds = match admin_bind_k8s_credentials(store, &env_id, admin.profile())? {
219 Some(c) => Some(c),
220 None => admin_bind_aws_credentials(store, &env_id, admin.profile())?,
221 };
222 Some(creds.ok_or_else(|| {
223 OpError::Conflict(
224 "`bind: true` is supported only for K8s- and AWS-bound environments this round; \
225 other deployers still bootstrap a render-only rules pack — drop `bind` and \
226 apply the pack offline, then `op credentials rotate`"
227 .to_string(),
228 )
229 })?)
230 } else {
231 None
232 };
233
234 let ctx = AuditCtx {
235 env_id: env_id.clone(),
236 noun: NOUN,
237 verb: "bootstrap",
238 target: json!({"admin_profile": payload.admin_profile, "bind": bind_requested}),
241 idempotency_key: None,
242 };
243 audit_and_record(store, ctx, |committed| {
244 let doc = match run_bootstrap(
249 store,
250 registry,
251 &env_id,
252 &admin,
253 bind_creds.as_deref(),
254 &dev_store_secret_sink,
255 ) {
256 Ok(d) => d,
257 Err(e) => {
258 if let Some(creds) = bind_creds.as_deref() {
263 creds.rollback_bound_material(&env_id);
264 }
265 return Err(map_bootstrap_err(e));
266 }
267 };
268 committed.mark_committed();
269
270 Ok((
271 OpOutcome::new(
272 NOUN,
273 "bootstrap",
274 json!({
275 "environment_id": env_id.as_str(),
276 "deployer_kind": doc.deployer_kind.as_str(),
277 "mode": "bootstrap",
278 "bound": bind_requested,
279 "credentials_ref": doc.provided_credentials_ref.as_str(),
280 "expires_at": doc.expiry.as_ref().map(|e| e.expires_at),
281 "rules_pack_ref": doc.bootstrap.as_ref().map(|b| b.rules_pack_ref.display().to_string()),
282 "admin_credential_consumed_at": doc.bootstrap.as_ref().map(|b| b.admin_credential_consumed_at),
283 }),
284 ),
285 AuditGens::NONE,
286 ))
287 })
288}
289
290pub fn rotate(
291 store: &LocalFsStore,
292 registry: &EnvPackRegistry,
293 flags: &OpFlags,
294 payload: Option<CredentialsRotatePayload>,
295) -> Result<OpOutcome, OpError> {
296 if flags.schema_only {
297 return Ok(OpOutcome::new(NOUN, "rotate", rotate_schema()));
298 }
299 let mut payload = resolve_payload::<CredentialsRotatePayload>(flags, payload)?;
300 let env_id = parse_env_id(&payload.environment_id)?;
301 let if_needed = payload.if_needed;
302
303 let env = store.load(&env_id).map_err(|e| match e {
307 crate::environment::StoreError::NotFound(_) => {
308 OpError::NotFound(format!("environment `{env_id}`"))
309 }
310 other => OpError::Store(other),
311 })?;
312 if env
313 .pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer)
314 .is_none()
315 {
316 return Err(OpError::Conflict(format!(
317 "env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first"
318 )));
319 }
320 if env.credentials_ref.is_none() {
321 return Err(OpError::Conflict(format!(
322 "env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
323 )));
324 }
325
326 let bind_creds = admin_bind_k8s_credentials(store, &env_id, &payload.admin_profile)?
332 .ok_or_else(|| {
333 OpError::Conflict(
334 "live rotation is only supported for K8s-bound environments this round; \
335 other deployers re-bind via `op credentials bootstrap` against a freshly \
336 applied rules pack"
337 .to_string(),
338 )
339 })?;
340
341 let ctx = AuditCtx {
342 env_id: env_id.clone(),
343 noun: NOUN,
344 verb: "rotate",
345 target: json!({"admin_profile": payload.admin_profile, "if_needed": if_needed}),
348 idempotency_key: None,
349 };
350 audit_and_record(store, ctx, |committed| {
355 if if_needed
360 && let Ok(Some(bearer)) =
361 super::secrets::resolve_credentials_token(store, &env, &env_id)
362 && !bind_creds.rotation_due(&bearer, chrono::Utc::now())
363 {
364 return Ok((
365 OpOutcome::new(
366 NOUN,
367 "rotate",
368 json!({
369 "environment_id": env_id.as_str(),
370 "rotated": false,
371 "reason": "current token has not reached its rotation threshold (80% of lifetime)",
372 }),
373 ),
374 AuditGens::NONE,
375 ));
376 }
377
378 let admin = load_admin_credential(
379 &payload.admin_profile,
380 payload.admin_material_path.as_deref(),
381 &mut payload.admin_material_inline,
382 )
383 .map_err(|e| OpError::Conflict(e.to_string()))?;
384
385 let outcome = crate::credentials::run_rotate(
386 store,
387 registry,
388 &env_id,
389 &admin,
390 bind_creds.as_ref(),
391 &dev_store_secret_sink,
392 )
393 .map_err(map_rotate_err)?;
394 committed.mark_committed();
395 Ok((
396 OpOutcome::new(
397 NOUN,
398 "rotate",
399 json!({
400 "environment_id": env_id.as_str(),
401 "rotated": true,
402 "credentials_ref": outcome.credentials_ref.as_str(),
403 "expires_at": outcome.expiry.as_ref().map(|e| e.expires_at),
404 "rotate_at": outcome.expiry.as_ref().and_then(|e| e.rotate_at),
405 }),
406 ),
407 AuditGens::NONE,
408 ))
409 })
410}
411
412fn result_label(r: &greentic_deploy_spec::CredentialsValidationResult) -> &'static str {
415 match r {
416 greentic_deploy_spec::CredentialsValidationResult::Pass => "pass",
417 greentic_deploy_spec::CredentialsValidationResult::Fail => "fail",
418 }
419}
420
421fn load_admin_credential(
431 admin_profile: &str,
432 admin_material_path: Option<&Path>,
433 admin_material_inline: &mut Option<String>,
434) -> Result<ZeroizedAdmin, AdminLoadError> {
435 match (admin_material_path, admin_material_inline.as_ref()) {
436 (None, None) => Err(AdminLoadError::Missing),
437 (Some(_), Some(_)) => Err(AdminLoadError::Both),
438 (Some(path), None) => {
439 let mut bytes =
440 Zeroizing::new(std::fs::read(path).map_err(|source| AdminLoadError::Io {
441 path: path.to_path_buf(),
442 source,
443 })?);
444 let raw = std::mem::take(&mut *bytes);
450 let material = String::from_utf8(raw).map_err(|_| AdminLoadError::NonUtf8 {
451 path: path.to_path_buf(),
452 })?;
453 if material.trim().is_empty() {
454 return Err(AdminLoadError::Empty);
455 }
456 Ok(ZeroizedAdmin::new(admin_profile, material))
457 }
458 (None, Some(_)) => {
459 let taken = admin_material_inline
462 .take()
463 .expect("matched Some branch; take cannot be None");
464 if taken.trim().is_empty() {
465 return Err(AdminLoadError::Empty);
466 }
467 Ok(ZeroizedAdmin::new(admin_profile, taken))
468 }
469 }
470}
471
472fn no_deployer_bound_msg(env_id: &EnvId) -> String {
478 format!("env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first")
479}
480
481fn handler_not_registered_msg(kind: &str) -> String {
482 format!(
483 "deployer env-pack `{kind}` has no native credentials handler registered (Phase D plug-in)"
484 )
485}
486
487fn map_validate_err(e: ValidateError) -> OpError {
488 match e {
489 ValidateError::NoDeployerBound(env_id) => OpError::Conflict(no_deployer_bound_msg(&env_id)),
490 ValidateError::NoCredentialsRef(env_id) => OpError::Conflict(format!(
491 "env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
492 )),
493 ValidateError::HandlerNotRegistered { kind } => {
494 OpError::Conflict(handler_not_registered_msg(&kind))
495 }
496 ValidateError::Store(s) => OpError::Store(s),
497 ValidateError::Registry(r) => OpError::Conflict(r.to_string()),
498 }
499}
500
501#[cfg(feature = "k8s-client")]
516fn connected_k8s_credentials(
517 store: &LocalFsStore,
518 env_id: &EnvId,
519) -> Result<Option<K8sDeployerCredentials>, OpError> {
520 use crate::cli::env::load_render_answers;
521 use crate::env_packs::k8s::K8sDeployerHandler;
522 use crate::env_packs::k8s::credentials::{
523 K8sValidatorClient, K8sValidatorConnectFut, K8sValidatorConnector,
524 };
525 use crate::env_packs::k8s::kube_client::{KubeValidatorClient, connect};
526 use crate::env_packs::k8s::manifests::{K8sParams, kubeconfig_context_from_answers};
527 use std::sync::Arc;
528
529 let Ok(env) = store.load(env_id) else {
532 return Ok(None);
533 };
534 let Some(binding) = env.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer) else {
535 return Ok(None);
536 };
537 if binding.kind.path() != K8sDeployerHandler::DESCRIPTOR_PATH {
538 return Ok(None);
539 }
540
541 let (answers, _wire) = load_render_answers(store, &env, &binding.kind)?;
545 let kubeconfig_context = kubeconfig_context_from_answers(answers.as_ref());
546 let namespace = K8sParams::from_answers(&env, answers.as_ref())
549 .map_err(|e| OpError::Conflict(format!("invalid K8s answers: {e}")))?
550 .namespace;
551 let bound_token =
557 crate::env_packs::k8s::resolve_bound_identity(store, &env, env_id, answers.as_ref())?;
558 let connector: K8sValidatorConnector = Arc::new(move || -> K8sValidatorConnectFut {
564 let kubeconfig_context = kubeconfig_context.clone();
565 let bound_token = bound_token.clone();
566 Box::pin(async move {
567 let client = connect(kubeconfig_context.as_deref(), bound_token.as_deref()).await?;
568 Ok(Arc::new(KubeValidatorClient::new(client)) as Arc<dyn K8sValidatorClient>)
569 })
570 });
571 Ok(Some(
572 K8sDeployerCredentials::with_connector(connector).in_namespace(namespace),
573 ))
574}
575
576#[cfg(not(feature = "k8s-client"))]
579fn connected_k8s_credentials(
580 _store: &LocalFsStore,
581 _env_id: &EnvId,
582) -> Result<Option<K8sDeployerCredentials>, OpError> {
583 Ok(None)
584}
585
586#[cfg(feature = "k8s-client")]
594fn admin_bind_k8s_credentials(
595 store: &LocalFsStore,
596 env_id: &EnvId,
597 admin_profile: &str,
598) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
599 use crate::cli::env::load_render_answers;
600 use crate::env_packs::k8s::K8sDeployerHandler;
601 use crate::env_packs::k8s::credentials::{
602 K8sBootstrapClient, K8sBootstrapConnectFut, K8sBootstrapConnector, K8sDeployerCredentials,
603 };
604 use crate::env_packs::k8s::kube_client::{KubeBootstrapClient, connect};
605 use crate::env_packs::k8s::manifests::K8sParams;
606 use std::sync::Arc;
607
608 let Ok(env) = store.load(env_id) else {
611 return Ok(None);
612 };
613 let Some(binding) = env.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer) else {
614 return Ok(None);
615 };
616 if binding.kind.path() != K8sDeployerHandler::DESCRIPTOR_PATH {
617 return Ok(None);
618 }
619
620 let (answers, _wire) = load_render_answers(store, &env, &binding.kind)?;
627 let namespace = K8sParams::from_answers(&env, answers.as_ref())
628 .map_err(|e| OpError::Conflict(format!("invalid K8s answers: {e}")))?
629 .namespace;
630
631 let admin_context = admin_profile.to_string();
632 let connector: K8sBootstrapConnector = Arc::new(move || -> K8sBootstrapConnectFut {
633 let admin_context = admin_context.clone();
634 Box::pin(async move {
635 let client = connect(Some(&admin_context), None).await?;
639 Ok(Arc::new(KubeBootstrapClient::new(client)) as Arc<dyn K8sBootstrapClient>)
640 })
641 });
642 Ok(Some(Box::new(
643 K8sDeployerCredentials::with_bootstrap_connector(connector).in_namespace(namespace),
644 )))
645}
646
647#[cfg(not(feature = "k8s-client"))]
650fn admin_bind_k8s_credentials(
651 _store: &LocalFsStore,
652 _env_id: &EnvId,
653 _admin_profile: &str,
654) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
655 Err(OpError::Conflict(
656 "`bind: true` requires a build with the `k8s-client` feature".to_string(),
657 ))
658}
659
660#[cfg(feature = "creds-aws")]
668fn admin_bind_aws_credentials(
669 store: &LocalFsStore,
670 env_id: &EnvId,
671 admin_profile: &str,
672) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
673 use crate::cli::env::load_render_answers;
674 use crate::env_packs::aws::AwsEcsDeployerHandler;
675 use crate::env_packs::aws::credentials::{
676 AwsBootstrapClient, AwsBootstrapConnectFut, AwsBootstrapConnector, AwsDeployerCredentials,
677 RealAwsBootstrapClient,
678 };
679 use crate::env_packs::aws::deployer::AwsEcsParams;
680 use std::sync::Arc;
681
682 let Ok(env) = store.load(env_id) else {
683 return Ok(None);
684 };
685 let Some(binding) = env.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer) else {
686 return Ok(None);
687 };
688 if binding.kind.path() != AwsEcsDeployerHandler::DESCRIPTOR_PATH {
689 return Ok(None);
690 }
691
692 let (answers, _wire) = load_render_answers(store, &env, &binding.kind)?;
696 let role_arn = AwsEcsParams::from_answers(&env, answers.as_ref())
697 .map_err(|e| OpError::Conflict(format!("invalid AWS answers: {e}")))?
698 .assume_role_arn
699 .ok_or_else(|| {
700 OpError::Conflict(
701 "AWS `bind: true` requires the binding's `assume_role_arn` answer (the deployer \
702 role the rules-pack Terraform creates); set it via the env wizard, or drop \
703 `bind` and apply the pack offline, then `op credentials rotate`"
704 .to_string(),
705 )
706 })?;
707
708 let profile = admin_profile.to_string();
709 let connector: AwsBootstrapConnector = Arc::new(move || -> AwsBootstrapConnectFut {
710 let profile = profile.clone();
711 Box::pin(async move {
712 let client = RealAwsBootstrapClient::resolve(&profile).await?;
715 Ok(Arc::new(client) as Arc<dyn AwsBootstrapClient>)
716 })
717 });
718 Ok(Some(Box::new(
719 AwsDeployerCredentials::with_bootstrap_assume(role_arn, connector),
720 )))
721}
722
723#[cfg(not(feature = "creds-aws"))]
726fn admin_bind_aws_credentials(
727 _store: &LocalFsStore,
728 _env_id: &EnvId,
729 _admin_profile: &str,
730) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
731 Err(OpError::Conflict(
732 "`bind: true` for AWS requires a build with the `creds-aws` feature".to_string(),
733 ))
734}
735
736fn dev_store_secret_sink(
741 env_root: &Path,
742 secret_ref: &greentic_deploy_spec::SecretRef,
743 value: &str,
744) -> Result<(), String> {
745 super::secrets::put_credential_material(env_root, secret_ref, value).map_err(|e| e.to_string())
746}
747
748fn map_bootstrap_err(e: RunBootstrapError) -> OpError {
749 use crate::credentials::BootstrapError;
750 match e {
751 RunBootstrapError::NoDeployerBound(env_id) => {
752 OpError::Conflict(no_deployer_bound_msg(&env_id))
753 }
754 RunBootstrapError::AlreadyBootstrapped(env_id) => OpError::Conflict(format!(
755 "env `{env_id}` already has credentials_ref; use `rotate` instead of `bootstrap`"
756 )),
757 RunBootstrapError::HandlerNotRegistered { kind } => {
758 OpError::Conflict(handler_not_registered_msg(&kind))
759 }
760 RunBootstrapError::Store(s) => OpError::Store(s),
761 RunBootstrapError::Registry(r) => OpError::Conflict(r.to_string()),
762 RunBootstrapError::Bootstrap(BootstrapError::NotApplicable(msg)) => OpError::Conflict(msg),
763 RunBootstrapError::Bootstrap(BootstrapError::AdminRejected(msg)) => {
764 OpError::Conflict(format!("admin credential rejected: {msg}"))
765 }
766 RunBootstrapError::Bootstrap(BootstrapError::ProvisioningFailed { step, message }) => {
767 OpError::Conflict(format!("bootstrap failed during {step}: {message}"))
768 }
769 RunBootstrapError::RulesExport(r) => OpError::Conflict(format!("rules export: {r}")),
770 RunBootstrapError::SecretWrite(msg) => OpError::Conflict(format!(
771 "failed to persist bound credential material: {msg}"
772 )),
773 }
774}
775
776fn map_rotate_err(e: crate::credentials::RunRotateError) -> OpError {
777 use crate::credentials::{BootstrapError, RunRotateError as E};
778 match e {
779 E::NoDeployerBound(env_id) => OpError::Conflict(no_deployer_bound_msg(&env_id)),
780 E::NotBootstrapped(env_id) => OpError::Conflict(format!(
781 "env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
782 )),
783 E::RotationUnsupported(msg) => OpError::Conflict(msg),
784 E::Store(s) => OpError::Store(s),
785 E::Registry(r) => OpError::Conflict(r.to_string()),
786 E::Bootstrap(BootstrapError::NotApplicable(msg)) => OpError::Conflict(msg),
787 E::Bootstrap(BootstrapError::AdminRejected(msg)) => {
788 OpError::Conflict(format!("admin credential rejected: {msg}"))
789 }
790 E::Bootstrap(BootstrapError::ProvisioningFailed { step, message }) => {
791 OpError::Conflict(format!("rotation failed during {step}: {message}"))
792 }
793 E::SecretWrite(msg) => OpError::Conflict(format!(
794 "failed to persist rotated credential material: {msg}"
795 )),
796 }
797}
798
799fn resolve_payload<T: serde::de::DeserializeOwned>(
800 flags: &OpFlags,
801 payload: Option<T>,
802) -> Result<T, OpError> {
803 if let Some(p) = payload {
804 return Ok(p);
805 }
806 if let Some(path) = &flags.answers {
807 return super::load_answers::<T>(path);
808 }
809 Err(OpError::InvalidArgument(
810 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
811 ))
812}
813
814fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
815 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
816}
817
818fn req_schema() -> Value {
819 json!({
820 "$schema": "https://json-schema.org/draft/2020-12/schema",
821 "title": "CredentialsRequirementsPayload",
822 "type": "object",
823 "required": ["environment_id"],
824 "additionalProperties": false,
825 "properties": {"environment_id": {"type": "string"}}
826 })
827}
828
829fn bootstrap_schema() -> Value {
830 json!({
831 "$schema": "https://json-schema.org/draft/2020-12/schema",
832 "title": "CredentialsBootstrapPayload",
833 "type": "object",
834 "required": ["environment_id", "admin_profile"],
835 "additionalProperties": false,
836 "properties": {
837 "environment_id": {"type": "string"},
838 "admin_profile": {"type": "string"},
839 "admin_material_path": {"type": "string", "description": "path to a file holding the admin credential material (zeroized on drop)"},
840 "admin_material_inline": {"type": "string", "description": "inline admin credential material (zeroized on drop); mutually exclusive with admin_material_path"},
841 "bind": {"type": "boolean", "description": "K8s only: connect as the admin (admin_profile kubeconfig context), apply the RBAC live, mint + bind the ServiceAccount token instead of emitting a render-only rules pack"}
842 }
843 })
844}
845
846fn rotate_schema() -> Value {
847 json!({
848 "$schema": "https://json-schema.org/draft/2020-12/schema",
849 "title": "CredentialsRotatePayload",
850 "type": "object",
851 "required": ["environment_id", "admin_profile"],
852 "additionalProperties": false,
853 "properties": {
854 "environment_id": {"type": "string"},
855 "admin_profile": {"type": "string", "description": "kubeconfig context / admin identity that re-mints the bound credential (never persisted)"},
856 "admin_material_path": {"type": "string", "description": "path to a file holding the admin credential material (zeroized on drop); mutually exclusive with admin_material_inline"},
857 "admin_material_inline": {"type": "string", "description": "inline admin credential material (zeroized on drop); mutually exclusive with admin_material_path"},
858 "if_needed": {"type": "boolean", "description": "rotate only if the current bound token is at/past 80% of its lifetime; a no-op otherwise (the idempotent form a scheduler calls)"}
859 }
860 })
861}
862
863#[cfg(test)]
871pub(crate) fn requirements_default(
872 store: &LocalFsStore,
873 flags: &OpFlags,
874 payload: Option<CredentialsRequirementsPayload>,
875) -> Result<OpOutcome, OpError> {
876 requirements(store, &EnvPackRegistry::with_builtins(), flags, payload)
877}
878
879#[cfg(test)]
880pub(crate) fn bootstrap_default(
881 store: &LocalFsStore,
882 flags: &OpFlags,
883 payload: Option<CredentialsBootstrapPayload>,
884) -> Result<OpOutcome, OpError> {
885 bootstrap(store, &EnvPackRegistry::with_builtins(), flags, payload)
886}
887
888#[cfg(test)]
889pub(crate) fn rotate_default(
890 store: &LocalFsStore,
891 flags: &OpFlags,
892 payload: Option<CredentialsRotatePayload>,
893) -> Result<OpOutcome, OpError> {
894 rotate(store, &EnvPackRegistry::with_builtins(), flags, payload)
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::cli::tests_common::{make_binding, make_env};
901 use crate::environment::EnvironmentStore;
902 use greentic_deploy_spec::{CapabilitySlot, SecretRef};
903 use tempfile::tempdir;
904
905 #[test]
906 fn requirements_rejects_env_without_deployer() {
907 let dir = tempdir().unwrap();
908 let store = LocalFsStore::new(dir.path());
909 store.save(&make_env("local")).unwrap();
910 let err = requirements_default(
911 &store,
912 &OpFlags::default(),
913 Some(CredentialsRequirementsPayload {
914 environment_id: "local".to_string(),
915 }),
916 )
917 .unwrap_err();
918 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
919 }
920
921 #[test]
924 fn requirements_passes_for_no_material_deployer_without_credentials_ref() {
925 let dir = tempdir().unwrap();
926 let store = LocalFsStore::new(dir.path());
927 let mut env = make_env("local");
928 env.packs.push(make_binding(
929 CapabilitySlot::Deployer,
930 "greentic.deployer.local-process@0.1.0",
931 ));
932 store.save(&env).unwrap();
933 let outcome = requirements_default(
934 &store,
935 &OpFlags::default(),
936 Some(CredentialsRequirementsPayload {
937 environment_id: "local".to_string(),
938 }),
939 )
940 .unwrap();
941 assert_eq!(outcome.result["mode"], "requirements");
942 assert_eq!(outcome.result["result"], "pass");
943 }
944
945 #[cfg(feature = "k8s-client")]
950 #[test]
951 fn connected_k8s_credentials_is_none_for_non_k8s_deployer() {
952 let dir = tempdir().unwrap();
953 let store = LocalFsStore::new(dir.path());
954 let mut env = make_env("local");
955 env.packs.push(make_binding(
956 CapabilitySlot::Deployer,
957 "greentic.deployer.local-process@0.1.0",
958 ));
959 store.save(&env).unwrap();
960 let got = connected_k8s_credentials(&store, &EnvId::try_from("local").unwrap())
961 .expect("non-K8s detection never errors");
962 assert!(
963 got.is_none(),
964 "non-K8s deployer must not connect a validator client"
965 );
966 }
967
968 #[test]
974 fn requirements_with_unregistered_deployer_kind_yields_conflict() {
975 let dir = tempdir().unwrap();
976 let store = LocalFsStore::new(dir.path());
977 let mut env = make_env("local");
978 env.packs.push(make_binding(
979 CapabilitySlot::Deployer,
980 "acme.deployer.fictional@1.0.0",
986 ));
987 env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
988 store.save(&env).unwrap();
989 let err = requirements_default(
990 &store,
991 &OpFlags::default(),
992 Some(CredentialsRequirementsPayload {
993 environment_id: "local".to_string(),
994 }),
995 )
996 .unwrap_err();
997 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1001 }
1002
1003 #[test]
1004 fn bootstrap_rejects_when_creds_already_set() {
1005 let dir = tempdir().unwrap();
1006 let store = LocalFsStore::new(dir.path());
1007 let mut env = make_env("local");
1008 env.packs.push(make_binding(
1009 CapabilitySlot::Deployer,
1010 "greentic.deployer.aws-ecs@1.0.0",
1011 ));
1012 env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
1013 store.save(&env).unwrap();
1014 let err = bootstrap_default(
1015 &store,
1016 &OpFlags::default(),
1017 Some(CredentialsBootstrapPayload {
1018 environment_id: "local".to_string(),
1019 admin_profile: "admin".to_string(),
1020 admin_material_path: None,
1021 admin_material_inline: Some("ADMIN_TOKEN".to_string()),
1022 bind: false,
1023 }),
1024 )
1025 .unwrap_err();
1026 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1027 }
1028
1029 #[test]
1030 fn bootstrap_requires_admin_material() {
1031 let dir = tempdir().unwrap();
1032 let store = LocalFsStore::new(dir.path());
1033 let mut env = make_env("local");
1034 env.packs.push(make_binding(
1035 CapabilitySlot::Deployer,
1036 "greentic.deployer.aws-ecs@1.0.0",
1037 ));
1038 store.save(&env).unwrap();
1039 let err = bootstrap_default(
1040 &store,
1041 &OpFlags::default(),
1042 Some(CredentialsBootstrapPayload {
1043 environment_id: "local".to_string(),
1044 admin_profile: "admin".to_string(),
1045 admin_material_path: None,
1046 admin_material_inline: None,
1047 bind: false,
1048 }),
1049 )
1050 .unwrap_err();
1051 assert!(
1052 matches!(err, OpError::Conflict(ref m) if m.contains("no admin material")),
1053 "got {err:?}"
1054 );
1055 }
1056
1057 #[test]
1058 fn bootstrap_rejects_both_path_and_inline() {
1059 let dir = tempdir().unwrap();
1060 let store = LocalFsStore::new(dir.path());
1061 let mut env = make_env("local");
1062 env.packs.push(make_binding(
1063 CapabilitySlot::Deployer,
1064 "greentic.deployer.aws-ecs@1.0.0",
1065 ));
1066 store.save(&env).unwrap();
1067 let err = bootstrap_default(
1068 &store,
1069 &OpFlags::default(),
1070 Some(CredentialsBootstrapPayload {
1071 environment_id: "local".to_string(),
1072 admin_profile: "admin".to_string(),
1073 admin_material_path: Some("/tmp/x".into()),
1074 admin_material_inline: Some("y".into()),
1075 bind: false,
1076 }),
1077 )
1078 .unwrap_err();
1079 assert!(
1080 matches!(err, OpError::Conflict(ref m) if m.contains("cannot supply both")),
1081 "got {err:?}"
1082 );
1083 }
1084
1085 #[test]
1090 fn requirements_against_c2_local_process_handler_returns_pass() {
1091 use crate::defaults::LOCAL_DEPLOYER_PACK;
1092
1093 let dir = tempdir().unwrap();
1094 let store = LocalFsStore::new(dir.path());
1095 let mut env = make_env("local");
1096 env.packs
1097 .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1098 env.credentials_ref =
1099 Some(SecretRef::try_new("secret://local/credentials/local-process").unwrap());
1100 store.save(&env).unwrap();
1101
1102 let registry = {
1105 let mut r = EnvPackRegistry::new();
1106 for h in crate::env_packs::BUILTIN_HANDLERS {
1107 r.register(Box::new(*h)).unwrap();
1108 }
1109 r.register(Box::new(
1110 crate::env_packs::LocalProcessDeployerHandler::with_port_range(49000..=49100),
1111 ))
1112 .unwrap();
1113 r
1114 };
1115
1116 let outcome = requirements(
1117 &store,
1118 ®istry,
1119 &OpFlags::default(),
1120 Some(CredentialsRequirementsPayload {
1121 environment_id: "local".to_string(),
1122 }),
1123 )
1124 .expect("requirements should succeed against c2 local-process");
1125 assert_eq!(outcome.op, "requirements");
1126 assert_eq!(outcome.noun, NOUN);
1127 assert_eq!(outcome.result["result"], "pass");
1128 assert!(
1129 outcome.result["missing_capabilities"]
1130 .as_array()
1131 .map(|a| a.is_empty())
1132 .unwrap_or(false),
1133 "no missing caps; got {outcome:?}"
1134 );
1135 let checks = outcome.result["checks"].as_array().unwrap();
1136 assert_eq!(checks.len(), 2);
1137 }
1138
1139 #[test]
1143 fn bootstrap_against_c2_local_process_handler_refuses_as_not_applicable() {
1144 use crate::defaults::LOCAL_DEPLOYER_PACK;
1145
1146 let dir = tempdir().unwrap();
1147 let store = LocalFsStore::new(dir.path());
1148 let mut env = make_env("local");
1149 env.packs
1150 .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1151 store.save(&env).unwrap();
1152
1153 let err = bootstrap_default(
1154 &store,
1155 &OpFlags::default(),
1156 Some(CredentialsBootstrapPayload {
1157 environment_id: "local".to_string(),
1158 admin_profile: "admin".to_string(),
1159 admin_material_path: None,
1160 admin_material_inline: Some("any-admin-token".to_string()),
1161 bind: false,
1162 }),
1163 )
1164 .unwrap_err();
1165 match err {
1166 OpError::Conflict(msg) => {
1167 assert!(
1168 msg.contains("no admin escalation") || msg.contains("requirements"),
1169 "Conflict message should point user at `requirements`, got: {msg}"
1170 );
1171 }
1172 other => panic!("expected Conflict (NotApplicable mapped), got {other:?}"),
1173 }
1174 let reloaded = store.load(&"local".try_into().unwrap()).unwrap();
1176 assert!(reloaded.credentials_ref.is_none());
1177 }
1178
1179 fn rotate_payload(env_id: &str) -> CredentialsRotatePayload {
1183 CredentialsRotatePayload {
1184 environment_id: env_id.to_string(),
1185 admin_profile: "admin-ctx".to_string(),
1186 admin_material_path: None,
1187 admin_material_inline: None,
1188 if_needed: false,
1189 }
1190 }
1191
1192 #[test]
1193 fn rotate_rejects_env_without_credentials_ref() {
1194 let dir = tempdir().unwrap();
1195 let store = LocalFsStore::new(dir.path());
1196 let mut env = make_env("local");
1197 env.packs.push(make_binding(
1198 CapabilitySlot::Deployer,
1199 "greentic.deployer.aws-ecs@1.0.0",
1200 ));
1201 store.save(&env).unwrap();
1202 let err =
1203 rotate_default(&store, &OpFlags::default(), Some(rotate_payload("local"))).unwrap_err();
1204 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1205 }
1206
1207 #[test]
1211 fn rotate_rejects_non_k8s_deployer() {
1212 let dir = tempdir().unwrap();
1213 let store = LocalFsStore::new(dir.path());
1214 let mut env = make_env("local");
1215 env.packs.push(make_binding(
1216 CapabilitySlot::Deployer,
1217 "greentic.deployer.local-process@0.1.0",
1218 ));
1219 env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/test").unwrap());
1220 store.save(&env).unwrap();
1221 let err =
1222 rotate_default(&store, &OpFlags::default(), Some(rotate_payload("local"))).unwrap_err();
1223 assert!(
1224 matches!(err, OpError::Conflict(_)),
1225 "non-K8s rotation must be a Conflict, got {err:?}"
1226 );
1227 }
1228
1229 #[test]
1233 fn no_material_deployer_requirements_without_credentials_ref() {
1234 use crate::credentials::{
1235 BootstrapError, BootstrapInput, BootstrapOutcome, Capability, DeployerCredentials,
1236 RequirementsReport, ValidationContext,
1237 };
1238 use crate::env_packs::EnvPackHandler;
1239
1240 #[derive(Debug)]
1241 struct NoMaterialHandler;
1242
1243 impl EnvPackHandler for NoMaterialHandler {
1244 fn slot(&self) -> CapabilitySlot {
1245 CapabilitySlot::Deployer
1246 }
1247 fn descriptor_path(&self) -> &str {
1248 "test.deployer.no-material"
1249 }
1250 fn supported_versions(&self) -> semver::VersionReq {
1251 "^0.1.0".parse().unwrap()
1252 }
1253 fn deployer_credentials(&self) -> Option<&dyn DeployerCredentials> {
1254 Some(&NoMaterialCreds)
1255 }
1256 }
1257
1258 #[derive(Debug)]
1259 struct NoMaterialCreds;
1260
1261 impl DeployerCredentials for NoMaterialCreds {
1262 fn requires_credentials_material(&self) -> bool {
1263 false
1264 }
1265 fn required_capabilities(&self) -> Vec<Capability> {
1266 Vec::new()
1267 }
1268 fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
1269 RequirementsReport::new(Vec::new())
1270 }
1271 fn bootstrap(
1272 &self,
1273 _input: &BootstrapInput<'_>,
1274 ) -> Result<BootstrapOutcome, BootstrapError> {
1275 Err(BootstrapError::NotApplicable(
1276 "no admin escalation".to_string(),
1277 ))
1278 }
1279 }
1280
1281 let dir = tempdir().unwrap();
1282 let store = LocalFsStore::new(dir.path());
1283 let mut env = make_env("local");
1284 env.packs.push(make_binding(
1285 CapabilitySlot::Deployer,
1286 "test.deployer.no-material@0.1.0",
1287 ));
1288 store.save(&env).unwrap();
1290
1291 let mut registry = EnvPackRegistry::new();
1292 registry.register(Box::new(NoMaterialHandler)).unwrap();
1293
1294 let outcome = requirements(
1295 &store,
1296 ®istry,
1297 &OpFlags::default(),
1298 Some(CredentialsRequirementsPayload {
1299 environment_id: "local".to_string(),
1300 }),
1301 )
1302 .unwrap();
1303 assert_eq!(outcome.result["mode"], "requirements");
1304 assert_eq!(outcome.result["result"], "pass");
1305 assert!(
1307 outcome.result["credentials_ref"]
1308 .as_str()
1309 .unwrap()
1310 .contains("no-material-required"),
1311 "expected sentinel credentials_ref, got {:?}",
1312 outcome.result["credentials_ref"]
1313 );
1314 }
1315
1316 #[test]
1323 fn requirements_default_passes_for_local_deployer_without_credentials_ref() {
1324 use crate::defaults::LOCAL_DEPLOYER_PACK;
1325
1326 let dir = tempdir().unwrap();
1327 let store = LocalFsStore::new(dir.path());
1328 let mut env = make_env("local");
1329 env.packs
1330 .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1331 store.save(&env).unwrap();
1333
1334 let outcome = requirements_default(
1335 &store,
1336 &OpFlags::default(),
1337 Some(CredentialsRequirementsPayload {
1338 environment_id: "local".to_string(),
1339 }),
1340 )
1341 .expect("default registry requirements should pass for local-process");
1342 assert_eq!(outcome.result["result"], "pass");
1343 let checks = outcome.result["checks"].as_array().unwrap();
1344 assert_eq!(
1345 checks.len(),
1346 2,
1347 "local-process declares 2 capabilities (fs + port)"
1348 );
1349 assert!(
1351 outcome.result["credentials_ref"]
1352 .as_str()
1353 .unwrap()
1354 .contains("no-material-required"),
1355 "expected sentinel ref, got {:?}",
1356 outcome.result["credentials_ref"]
1357 );
1358 }
1359
1360 #[derive(Debug)]
1363 struct BindResolveHandler;
1364
1365 impl crate::env_packs::EnvPackHandler for BindResolveHandler {
1366 fn slot(&self) -> CapabilitySlot {
1367 CapabilitySlot::Deployer
1368 }
1369 fn descriptor_path(&self) -> &str {
1370 "test.deployer.bind"
1371 }
1372 fn supported_versions(&self) -> semver::VersionReq {
1373 "^0.1.0".parse().unwrap()
1374 }
1375 fn deployer_credentials(&self) -> Option<&dyn crate::credentials::DeployerCredentials> {
1376 Some(&BindPlaceholderCreds)
1380 }
1381 }
1382
1383 #[derive(Debug)]
1386 struct BindPlaceholderCreds;
1387
1388 impl crate::credentials::DeployerCredentials for BindPlaceholderCreds {
1389 fn required_capabilities(&self) -> Vec<crate::credentials::Capability> {
1390 Vec::new()
1391 }
1392 fn validate(
1393 &self,
1394 _ctx: &crate::credentials::ValidationContext<'_>,
1395 ) -> crate::credentials::RequirementsReport {
1396 crate::credentials::RequirementsReport::new(Vec::new())
1397 }
1398 fn bootstrap(
1399 &self,
1400 _input: &crate::credentials::BootstrapInput<'_>,
1401 ) -> Result<crate::credentials::BootstrapOutcome, crate::credentials::BootstrapError>
1402 {
1403 Err(crate::credentials::BootstrapError::NotApplicable(
1404 "placeholder creds are never invoked".to_string(),
1405 ))
1406 }
1407 }
1408
1409 #[derive(Debug)]
1413 struct MintingCreds {
1414 secret_ref: String,
1415 token: String,
1416 expiry: chrono::DateTime<chrono::Utc>,
1417 }
1418
1419 impl crate::credentials::DeployerCredentials for MintingCreds {
1420 fn required_capabilities(&self) -> Vec<crate::credentials::Capability> {
1421 Vec::new()
1422 }
1423 fn validate(
1424 &self,
1425 _ctx: &crate::credentials::ValidationContext<'_>,
1426 ) -> crate::credentials::RequirementsReport {
1427 crate::credentials::RequirementsReport::new(Vec::new())
1428 }
1429 fn bootstrap(
1430 &self,
1431 _input: &crate::credentials::BootstrapInput<'_>,
1432 ) -> Result<crate::credentials::BootstrapOutcome, crate::credentials::BootstrapError>
1433 {
1434 Ok(crate::credentials::BootstrapOutcome {
1435 rules_pack: crate::credentials::RulesPack {
1436 entries: Vec::new(),
1437 },
1438 bound_credentials_ref: Some(SecretRef::try_new(self.secret_ref.clone()).unwrap()),
1439 bound_expiry: Some(self.expiry),
1440 bound_secret_material: Some(zeroize::Zeroizing::new(self.token.clone())),
1441 })
1442 }
1443 }
1444
1445 fn bind_fixture(dir: &std::path::Path) -> (LocalFsStore, EnvPackRegistry, EnvId) {
1446 let store = LocalFsStore::new(dir);
1447 let mut env = make_env("local");
1448 env.packs.push(make_binding(
1449 CapabilitySlot::Deployer,
1450 "test.deployer.bind@0.1.0",
1451 ));
1452 store.save(&env).unwrap();
1453 let mut registry = EnvPackRegistry::new();
1454 registry.register(Box::new(BindResolveHandler)).unwrap();
1455 (store, registry, EnvId::try_from("local").unwrap())
1456 }
1457
1458 #[cfg(feature = "creds-aws")]
1459 #[test]
1460 fn aws_bind_requires_assume_role_arn() {
1461 let dir = tempdir().unwrap();
1462 let store = LocalFsStore::new(dir.path());
1463 let mut env = make_env("prod-eu");
1464 env.packs.push(make_binding(
1465 CapabilitySlot::Deployer,
1466 "greentic.deployer.aws-ecs@1.0.0",
1467 ));
1468 store.save(&env).unwrap();
1469 let env_id = EnvId::try_from("prod-eu").unwrap();
1470
1471 let err = admin_bind_aws_credentials(&store, &env_id, "admin-profile").unwrap_err();
1475 match err {
1476 OpError::Conflict(msg) => assert!(
1477 msg.contains("assume_role_arn"),
1478 "expected an assume_role_arn hint, got: {msg}"
1479 ),
1480 other => panic!("expected Conflict, got {other:?}"),
1481 }
1482 }
1483
1484 #[cfg(feature = "creds-aws")]
1485 #[test]
1486 fn aws_bind_returns_none_for_a_non_aws_deployer() {
1487 use crate::defaults::LOCAL_DEPLOYER_PACK;
1488 let dir = tempdir().unwrap();
1489 let store = LocalFsStore::new(dir.path());
1490 let mut env = make_env("local");
1491 env.packs
1492 .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1493 store.save(&env).unwrap();
1494 let env_id = EnvId::try_from("local").unwrap();
1495
1496 assert!(
1499 admin_bind_aws_credentials(&store, &env_id, "admin-profile")
1500 .unwrap()
1501 .is_none()
1502 );
1503 }
1504
1505 #[test]
1506 fn run_bootstrap_writes_material_then_persists_ref_and_expiry() {
1507 let dir = tempdir().unwrap();
1508 let (store, registry, env_id) = bind_fixture(dir.path());
1509 let admin = crate::credentials::ZeroizedAdmin::new("admin-ctx", String::new());
1510 let expiry = chrono::DateTime::from_timestamp(2_000_000_000, 0).unwrap();
1511 let bind = MintingCreds {
1512 secret_ref: "secret://local/default/_/k8s-deployer/deployer_token".to_string(),
1513 token: "MINTED".to_string(),
1514 expiry,
1515 };
1516
1517 let captured = std::sync::Mutex::new(None);
1518 let sink =
1519 |_root: &std::path::Path, secret_ref: &SecretRef, value: &str| -> Result<(), String> {
1520 *captured.lock().unwrap() =
1521 Some((secret_ref.as_str().to_string(), value.to_string()));
1522 Ok(())
1523 };
1524
1525 let doc = crate::credentials::run_bootstrap(
1526 &store,
1527 ®istry,
1528 &env_id,
1529 &admin,
1530 Some(&bind),
1531 &sink,
1532 )
1533 .expect("bind succeeds");
1534
1535 assert_eq!(doc.expiry.as_ref().map(|e| e.expires_at), Some(expiry));
1537 let (uri, value) = captured.lock().unwrap().clone().expect("sink invoked");
1539 assert_eq!(uri, "secret://local/default/_/k8s-deployer/deployer_token");
1540 assert_eq!(value, "MINTED");
1541 let reloaded = store.load(&env_id).unwrap();
1543 assert_eq!(
1544 reloaded.credentials_ref.as_ref().map(|r| r.as_str()),
1545 Some("secret://local/default/_/k8s-deployer/deployer_token")
1546 );
1547 }
1548
1549 #[test]
1550 fn run_bootstrap_aborts_without_persisting_ref_when_the_sink_fails() {
1551 let dir = tempdir().unwrap();
1552 let (store, registry, env_id) = bind_fixture(dir.path());
1553 let admin = crate::credentials::ZeroizedAdmin::new("admin-ctx", String::new());
1554 let bind = MintingCreds {
1555 secret_ref: "secret://local/default/_/k8s-deployer/deployer_token".to_string(),
1556 token: "MINTED".to_string(),
1557 expiry: chrono::DateTime::from_timestamp(2_000_000_000, 0).unwrap(),
1558 };
1559 let sink = |_root: &std::path::Path, _r: &SecretRef, _v: &str| -> Result<(), String> {
1560 Err("backend unavailable".to_string())
1561 };
1562
1563 let err = crate::credentials::run_bootstrap(
1564 &store,
1565 ®istry,
1566 &env_id,
1567 &admin,
1568 Some(&bind),
1569 &sink,
1570 )
1571 .unwrap_err();
1572 assert!(
1573 matches!(err, RunBootstrapError::SecretWrite(_)),
1574 "got {err:?}"
1575 );
1576 let reloaded = store.load(&env_id).unwrap();
1578 assert!(
1579 reloaded.credentials_ref.is_none(),
1580 "credentials_ref must not persist on sink failure"
1581 );
1582 }
1583}