use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Context {
#[serde(default = "default_api_version")]
pub api_version: String,
#[serde(default = "default_context_kind")]
pub kind: String,
pub metadata: ContextMetadata,
pub spec: ContextSpec,
}
fn default_api_version() -> String {
"aof.dev/v1".to_string()
}
fn default_context_kind() -> String {
"Context".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextMetadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub kubeconfig: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cluster: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval: Option<ApprovalConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit: Option<AuditConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limits: Option<LimitsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<SecretRef>,
#[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ApprovalConfig {
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_users: Vec<String>,
#[serde(default = "default_approval_timeout")]
pub timeout_seconds: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub require_for: Vec<String>,
#[serde(default)]
pub allow_self_approval: bool,
#[serde(default = "default_min_approvers")]
pub min_approvers: u32,
}
fn default_approval_timeout() -> u32 {
300 }
fn default_min_approvers() -> u32 {
1
}
impl Default for ApprovalConfig {
fn default() -> Self {
Self {
required: false,
allowed_users: Vec::new(),
timeout_seconds: default_approval_timeout(),
require_for: Vec::new(),
allow_self_approval: false,
min_approvers: default_min_approvers(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuditConfig {
#[serde(default)]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sink: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<AuditEvent>,
#[serde(default)]
pub include_payload: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub retention: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditEvent {
AgentStart,
AgentComplete,
ToolCall,
ApprovalRequested,
ApprovalGranted,
ApprovalDenied,
Error,
All,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct LimitsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_requests_per_minute: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens_per_day: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_execution_time_seconds: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_cost_per_day: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SecretRef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_var: Option<String>,
}
impl Context {
pub fn name(&self) -> &str {
&self.metadata.name
}
pub fn validate(&self) -> Result<(), String> {
if self.metadata.name.is_empty() {
return Err("Context name is required".to_string());
}
if let Some(ref approval) = self.spec.approval {
if approval.required && approval.allowed_users.is_empty() {
}
if approval.min_approvers < 1 {
return Err("min_approvers must be at least 1".to_string());
}
}
if let Some(ref limits) = self.spec.limits {
if let Some(max_concurrent) = limits.max_concurrent {
if max_concurrent == 0 {
return Err("max_concurrent must be greater than 0".to_string());
}
}
}
Ok(())
}
pub fn expand_env_vars(&mut self) {
if let Some(ref kubeconfig) = self.spec.kubeconfig {
self.spec.kubeconfig = Some(expand_env_var(kubeconfig));
}
let expanded_env: HashMap<String, String> = self
.spec
.env
.iter()
.map(|(k, v)| (k.clone(), expand_env_var(v)))
.collect();
self.spec.env = expanded_env;
if let Some(ref working_dir) = self.spec.working_dir {
self.spec.working_dir = Some(expand_env_var(working_dir));
}
if let Some(ref mut audit) = self.spec.audit {
if let Some(ref sink) = audit.sink {
audit.sink = Some(expand_env_var(sink));
}
}
}
pub fn get_env_vars(&self) -> HashMap<String, String> {
let mut env = self.spec.env.clone();
env.insert("AOF_CONTEXT".to_string(), self.metadata.name.clone());
if let Some(ref namespace) = self.spec.namespace {
env.insert("AOF_NAMESPACE".to_string(), namespace.clone());
}
if let Some(ref cluster) = self.spec.cluster {
env.insert("AOF_CLUSTER".to_string(), cluster.clone());
}
env
}
pub fn requires_approval(&self, command: &str) -> bool {
if let Some(ref approval) = self.spec.approval {
if !approval.required {
return false;
}
if approval.require_for.is_empty() {
return true;
}
for pattern in &approval.require_for {
if let Ok(re) = regex::Regex::new(pattern) {
if re.is_match(command) {
return true;
}
} else if command.contains(pattern) {
return true;
}
}
false
} else {
false
}
}
pub fn is_approver(&self, user_id: &str) -> bool {
if let Some(ref approval) = self.spec.approval {
if approval.allowed_users.is_empty() {
return true; }
approval.allowed_users.iter().any(|allowed| {
allowed == user_id
|| allowed == &format!("slack:{}", user_id)
|| allowed == &format!("telegram:{}", user_id)
|| allowed == &format!("discord:{}", user_id)
|| allowed.strip_prefix("slack:").map_or(false, |id| id == user_id)
|| allowed.strip_prefix("telegram:").map_or(false, |id| id == user_id)
|| allowed.strip_prefix("discord:").map_or(false, |id| id == user_id)
})
} else {
true }
}
}
fn expand_env_var(value: &str) -> String {
let mut result = value.to_string();
let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
for cap in re.captures_iter(value) {
let var_name = &cap[1];
if let Ok(var_value) = std::env::var(var_name) {
result = result.replace(&cap[0], &var_value);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_context() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: prod
labels:
environment: production
spec:
kubeconfig: ${KUBECONFIG_PROD}
namespace: production
env:
CLUSTER_NAME: prod-us-east-1
LOG_LEVEL: info
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert_eq!(ctx.metadata.name, "prod");
assert_eq!(ctx.spec.namespace, Some("production".to_string()));
assert_eq!(ctx.spec.env.get("CLUSTER_NAME"), Some(&"prod-us-east-1".to_string()));
assert!(ctx.validate().is_ok());
}
#[test]
fn test_parse_context_with_approval() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: prod
spec:
namespace: production
approval:
required: true
allowed_users:
- U015ADMIN
- slack:U016SRELEAD
timeout_seconds: 300
require_for:
- kubectl delete
- helm uninstall
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert!(ctx.spec.approval.is_some());
let approval = ctx.spec.approval.as_ref().unwrap();
assert!(approval.required);
assert_eq!(approval.allowed_users.len(), 2);
assert_eq!(approval.timeout_seconds, 300);
assert!(ctx.validate().is_ok());
}
#[test]
fn test_parse_context_with_audit() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: prod
spec:
audit:
enabled: true
sink: s3://company-audit/prod/
events:
- agent_start
- agent_complete
- tool_call
include_payload: false
retention: "90d"
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert!(ctx.spec.audit.is_some());
let audit = ctx.spec.audit.as_ref().unwrap();
assert!(audit.enabled);
assert_eq!(audit.sink, Some("s3://company-audit/prod/".to_string()));
assert_eq!(audit.events.len(), 3);
}
#[test]
fn test_parse_context_with_limits() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: staging
spec:
limits:
max_requests_per_minute: 100
max_tokens_per_day: 1000000
max_concurrent: 5
max_execution_time_seconds: 300
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert!(ctx.spec.limits.is_some());
let limits = ctx.spec.limits.as_ref().unwrap();
assert_eq!(limits.max_requests_per_minute, Some(100));
assert_eq!(limits.max_tokens_per_day, Some(1000000));
assert_eq!(limits.max_concurrent, Some(5));
}
#[test]
fn test_requires_approval() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: prod
spec:
approval:
required: true
require_for:
- "kubectl delete"
- "helm uninstall"
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert!(ctx.requires_approval("kubectl delete pod nginx"));
assert!(ctx.requires_approval("helm uninstall my-release"));
assert!(!ctx.requires_approval("kubectl get pods"));
}
#[test]
fn test_is_approver() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: prod
spec:
approval:
required: true
allowed_users:
- U015ADMIN
- slack:U016SRELEAD
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert!(ctx.is_approver("U015ADMIN"));
assert!(ctx.is_approver("U016SRELEAD"));
assert!(!ctx.is_approver("U999RANDOM"));
}
#[test]
fn test_get_env_vars() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: prod
spec:
namespace: production
cluster: prod-cluster
env:
CUSTOM_VAR: custom_value
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
let env = ctx.get_env_vars();
assert_eq!(env.get("AOF_CONTEXT"), Some(&"prod".to_string()));
assert_eq!(env.get("AOF_NAMESPACE"), Some(&"production".to_string()));
assert_eq!(env.get("AOF_CLUSTER"), Some(&"prod-cluster".to_string()));
assert_eq!(env.get("CUSTOM_VAR"), Some(&"custom_value".to_string()));
}
#[test]
fn test_validation_errors() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: ""
spec: {}
"#;
let ctx: Context = serde_yaml::from_str(yaml).unwrap();
assert!(ctx.validate().is_err());
let yaml2 = r#"
apiVersion: aof.dev/v1
kind: Context
metadata:
name: test
spec:
approval:
required: true
min_approvers: 0
"#;
let ctx2: Context = serde_yaml::from_str(yaml2).unwrap();
assert!(ctx2.validate().is_err());
}
#[test]
fn test_expand_env_var() {
std::env::set_var("TEST_VAR", "test_value");
let result = expand_env_var("prefix_${TEST_VAR}_suffix");
assert_eq!(result, "prefix_test_value_suffix");
std::env::remove_var("TEST_VAR");
}
}