Skip to main content

alien_core/
operations_config.rs

1//! Operations a stack declares: which plugins its deployments load, their
2//! settings, and which operations need approval.
3//!
4//! The shape is plugin-agnostic. Plugin manifests declare the settings they
5//! accept, and whoever creates a release validates these values against them.
6
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9
10/// Operations declared by a stack, or by an Operator installed on its own.
11#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct OperationsConfig {
15    /// Built-in plugins, by plugin name.
16    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
17    pub plugins: IndexMap<String, PluginOperationsConfig>,
18    /// Published custom plugins, pinned to exact versions.
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub custom: Vec<CustomPluginOperationsConfig>,
21}
22
23impl OperationsConfig {
24    /// Whether the config declares no plugin at all.
25    pub fn is_empty(&self) -> bool {
26        self.plugins.is_empty() && self.custom.is_empty()
27    }
28
29    /// The same declaration with every setting value removed, safe to report.
30    pub fn without_settings(&self) -> Self {
31        let strip = |config: &PluginOperationsConfig| PluginOperationsConfig {
32            settings: IndexMap::new(),
33            approval: config.approval.clone(),
34        };
35        Self {
36            plugins: self
37                .plugins
38                .iter()
39                .map(|(name, config)| (name.clone(), strip(config)))
40                .collect(),
41            custom: self
42                .custom
43                .iter()
44                .map(|plugin| CustomPluginOperationsConfig {
45                    name: plugin.name.clone(),
46                    version: plugin.version.clone(),
47                    config: strip(&plugin.config),
48                })
49                .collect(),
50        }
51    }
52}
53
54/// Settings and approval rules for one plugin.
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
57#[serde(rename_all = "camelCase")]
58pub struct PluginOperationsConfig {
59    /// Values for the settings the plugin's manifest declares.
60    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
61    pub settings: IndexMap<String, OperationSettingValue>,
62    /// Approval rule per operation: an operation name, or `*` for all of them.
63    /// Operations no rule matches need approval.
64    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
65    pub approval: IndexMap<String, OperationApproval>,
66}
67
68/// A published custom plugin at an exact version.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71#[serde(rename_all = "camelCase")]
72pub struct CustomPluginOperationsConfig {
73    /// Plugin name as published.
74    pub name: String,
75    /// Exact published version.
76    pub version: String,
77    /// Settings and approval rules.
78    #[serde(flatten)]
79    pub config: PluginOperationsConfig,
80}
81
82/// Value of one plugin setting.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[serde(untagged)]
86pub enum OperationSettingValue {
87    /// A literal value.
88    Literal(String),
89    /// The value of a stack input.
90    Input {
91        /// Stack input id.
92        input: String,
93    },
94    /// Stack resources the plugin may act on, such as buckets or queues.
95    Resources {
96        /// Resource ids in the same stack.
97        resources: Vec<String>,
98    },
99    /// An environment variable of the Operator process. Only for an Operator
100    /// installed on its own, so secrets stay in its environment.
101    Env {
102        /// Environment variable name.
103        env: String,
104    },
105}
106
107/// Approval rule for the operations a pattern matches.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110#[serde(untagged)]
111pub enum OperationApproval {
112    /// Run without approval, or require it.
113    Decision(OperationApprovalDecision),
114    /// A decision plus the highest risk tier a wildcard access request may cover.
115    Detailed {
116        /// Run without approval, or require it.
117        decision: OperationApprovalDecision,
118        /// Highest risk tier (`read-only`, `mutating`, `destructive`) a wildcard
119        /// access request for these operations may cover.
120        #[serde(rename = "maxRisk", default, skip_serializing_if = "Option::is_none")]
121        max_risk: Option<String>,
122    },
123}
124
125impl OperationApproval {
126    /// The decision this rule makes.
127    pub fn decision(&self) -> OperationApprovalDecision {
128        match self {
129            Self::Decision(decision) | Self::Detailed { decision, .. } => *decision,
130        }
131    }
132}
133
134/// Whether matching operations run without approval.
135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
136#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
137#[serde(rename_all = "lowercase")]
138pub enum OperationApprovalDecision {
139    /// Run without approval.
140    Auto,
141    /// Require approval.
142    Manual,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn round_trips_every_setting_and_approval_form() {
151        let json = serde_json::json!({
152            "plugins": {
153                "db": {
154                    "settings": {
155                        "url": { "input": "dbUrl" },
156                        "database": "app",
157                        "password": { "env": "DB_PASSWORD" },
158                        "buckets": { "resources": ["data"] }
159                    },
160                    "approval": { "*": "manual", "health": { "decision": "auto", "maxRisk": "read-only" } }
161                }
162            },
163            "custom": [{ "name": "mine", "version": "1.2.0", "approval": { "*": "auto" } }]
164        });
165        let config: OperationsConfig = serde_json::from_value(json.clone()).unwrap();
166        let db = &config.plugins["db"];
167        assert_eq!(
168            db.settings["database"],
169            OperationSettingValue::Literal("app".into())
170        );
171        assert_eq!(
172            db.settings["url"],
173            OperationSettingValue::Input {
174                input: "dbUrl".into()
175            }
176        );
177        assert_eq!(
178            db.approval["*"].decision(),
179            OperationApprovalDecision::Manual
180        );
181        assert_eq!(
182            db.approval["health"].decision(),
183            OperationApprovalDecision::Auto
184        );
185        assert_eq!(
186            config.custom[0].config.approval["*"].decision(),
187            OperationApprovalDecision::Auto
188        );
189        assert_eq!(serde_json::to_value(&config).unwrap(), json);
190    }
191}