use greentic_deploy_spec::{CapabilitySlot, EnvId, SecretRef};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::environment::{EnvironmentStore, LocalFsStore};
use super::{AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record};
const NOUN: &str = "credentials";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsRequirementsPayload {
pub environment_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsBootstrapPayload {
pub environment_id: String,
pub admin_profile: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsRotatePayload {
pub environment_id: String,
}
pub fn requirements(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<CredentialsRequirementsPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "requirements", req_schema()));
}
let payload = resolve_payload::<CredentialsRequirementsPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let env = store.load(&env_id)?;
let deployer = env.pack_for_slot(CapabilitySlot::Deployer).ok_or_else(|| {
OpError::Conflict(format!(
"env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first"
))
})?;
let creds_ref: &SecretRef = env.credentials_ref.as_ref().ok_or_else(|| {
OpError::Conflict(format!(
"env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
))
})?;
let _ = describe_intent("requirements", &env_id, deployer, Some(creds_ref));
Err(OpError::NotYetImplemented(
"credential validation depends on deployer env-pack `credentials.yaml`; lands in A5+Phase D",
))
}
pub fn bootstrap(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<CredentialsBootstrapPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "bootstrap", bootstrap_schema()));
}
let payload = resolve_payload::<CredentialsBootstrapPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "bootstrap",
target: json!({"admin_profile": payload.admin_profile}),
idempotency_key: None,
};
audit_and_record(store, ctx, || {
let env = store.load(&env_id)?;
let deployer = env.pack_for_slot(CapabilitySlot::Deployer).ok_or_else(|| {
OpError::Conflict(format!(
"env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first"
))
})?;
if env.credentials_ref.is_some() {
return Err(OpError::Conflict(format!(
"env `{env_id}` already has credentials_ref; use `rotate` instead of `bootstrap`"
)));
}
let _ = describe_intent("bootstrap", &env_id, deployer, None);
Err(OpError::NotYetImplemented(
"bootstrap runs the deployer env-pack's bootstrap module against ephemeral admin credentials; lands in A5+Phase D",
))
})
}
pub fn rotate(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<CredentialsRotatePayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "rotate", rotate_schema()));
}
let payload = resolve_payload::<CredentialsRotatePayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "rotate",
target: json!({}),
idempotency_key: None,
};
audit_and_record(store, ctx, || {
let env = store.load(&env_id)?;
let deployer = env.pack_for_slot(CapabilitySlot::Deployer).ok_or_else(|| {
OpError::Conflict(format!("env `{env_id}` has no deployer env-pack bound"))
})?;
let creds_ref: &SecretRef = env.credentials_ref.as_ref().ok_or_else(|| {
OpError::Conflict(format!("env `{env_id}` has no credentials_ref to rotate"))
})?;
let _ = describe_intent("rotate", &env_id, deployer, Some(creds_ref));
Err(OpError::NotYetImplemented(
"credential rotation depends on deployer-specific session/token rotation hooks; lands in A5+Phase D",
))
})
}
fn resolve_payload<T: serde::de::DeserializeOwned>(
flags: &OpFlags,
payload: Option<T>,
) -> Result<T, OpError> {
if let Some(p) = payload {
return Ok(p);
}
if let Some(path) = &flags.answers {
return super::load_answers::<T>(path);
}
Err(OpError::InvalidArgument(
"no payload provided: pass --answers <path> or supply the payload directly".to_string(),
))
}
fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
}
fn describe_intent(
op: &'static str,
env_id: &EnvId,
deployer: &greentic_deploy_spec::EnvPackBinding,
creds_ref: Option<&SecretRef>,
) -> Value {
json!({
"op": op,
"environment_id": env_id.as_str(),
"deployer_kind": deployer.kind.to_string(),
"deployer_pack_ref": deployer.pack_ref.as_str(),
"credentials_ref": creds_ref.map(|c| c.as_str()),
})
}
fn req_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CredentialsRequirementsPayload",
"type": "object",
"required": ["environment_id"],
"additionalProperties": false,
"properties": {"environment_id": {"type": "string"}}
})
}
fn bootstrap_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CredentialsBootstrapPayload",
"type": "object",
"required": ["environment_id", "admin_profile"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string"},
"admin_profile": {"type": "string"}
}
})
}
fn rotate_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CredentialsRotatePayload",
"type": "object",
"required": ["environment_id"],
"additionalProperties": false,
"properties": {"environment_id": {"type": "string"}}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tests_common::{make_binding, make_env};
use tempfile::tempdir;
#[test]
fn requirements_rejects_env_without_deployer() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let err = requirements(
&store,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn bootstrap_rejects_when_creds_already_set() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
store.save(&env).unwrap();
let err = bootstrap(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn bootstrap_with_deployer_no_creds_yields_not_yet_implemented() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
store.save(&env).unwrap();
let err = bootstrap(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
}
#[test]
fn rotate_rejects_env_without_credentials_ref() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
store.save(&env).unwrap();
let err = rotate(
&store,
&OpFlags::default(),
Some(CredentialsRotatePayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn requirements_with_complete_setup_yields_not_yet_implemented() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
store.save(&env).unwrap();
let err = requirements(
&store,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
}
#[test]
fn bootstrap_stub_records_not_yet_implemented_audit_result() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
store.save(&env).unwrap();
let err = bootstrap(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::NotYetImplemented(_)));
let log = dir.path().join("local").join("audit").join("events.jsonl");
let raw = std::fs::read_to_string(&log).unwrap();
let event: crate::environment::AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
match event.result {
crate::environment::AuditResult::NotYetImplemented { detail } => {
assert!(detail.contains("bootstrap"), "detail: {detail}");
}
other => panic!("expected NotYetImplemented, got {other:?}"),
}
}
}