Skip to main content

lenso_service/system_plane/
module_operations.rs

1use lenso_contracts::{
2    ConsoleContributionAction, ModuleConfigActivation, ModuleConfigFieldType,
3    ModuleConfigMutability, ModuleConfigScope, digest_json,
4};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::collections::{BTreeMap, BTreeSet};
9use utoipa::ToSchema;
10
11pub const MODULE_OPERATIONS_PROTOCOL: &str = "lenso.system-plane.module-operations.v1";
12pub const MODULE_OPERATIONS_PATH: &str = "/system-plane/v1/modules";
13pub const MODULE_OPERATIONS_FEATURE_INVENTORY_READ: &str = "module.inventory.read";
14pub const MODULE_OPERATIONS_FEATURE_CONTRIBUTIONS_RESOLVE: &str =
15    "module.action-contributions.resolve";
16pub const MODULE_OPERATIONS_FEATURE_CONFIG_READ: &str = "module.config.read";
17pub const MODULE_OPERATIONS_FEATURE_CONFIG_WRITE: &str = "module.config.write";
18
19/// The target and caller identity carried by every Module System Plane request.
20/// The target Service verifies this against its authenticated transport,
21/// enrollment grant, and installed Module declarations before executing an
22/// operation.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct ManagedServiceContext {
26    pub system_id: String,
27    pub service_id: String,
28    pub environment_id: String,
29    pub target_service_principal: String,
30    pub caller_module_id: String,
31    pub delegated_actor_subject: String,
32    pub delegated_authority_digest: String,
33    #[serde(default)]
34    pub capabilities: BTreeSet<String>,
35}
36
37impl ManagedServiceContext {
38    #[must_use]
39    pub fn new(
40        system_id: impl Into<String>,
41        service_id: impl Into<String>,
42        environment_id: impl Into<String>,
43        target_service_principal: impl Into<String>,
44        caller_module_id: impl Into<String>,
45        delegated_actor_subject: impl Into<String>,
46        delegated_authority_digest: impl Into<String>,
47        capabilities: impl IntoIterator<Item = impl Into<String>>,
48    ) -> Self {
49        Self {
50            system_id: system_id.into(),
51            service_id: service_id.into(),
52            environment_id: environment_id.into(),
53            target_service_principal: target_service_principal.into(),
54            caller_module_id: caller_module_id.into(),
55            delegated_actor_subject: delegated_actor_subject.into(),
56            delegated_authority_digest: delegated_authority_digest.into(),
57            capabilities: capabilities.into_iter().map(Into::into).collect(),
58        }
59    }
60
61    /// Stable digest used in operation evidence. It never includes a secret
62    /// value because the context contains only authority metadata.
63    pub fn digest(&self) -> Result<String, serde_json::Error> {
64        digest_json(self)
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct ModuleInventoryRequest {
71    pub context: ManagedServiceContext,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
75#[serde(rename_all = "camelCase", deny_unknown_fields)]
76pub struct ModuleInventorySnapshot {
77    pub protocol: String,
78    pub context: ManagedServiceContext,
79    pub service_revision: String,
80    pub snapshot_revision: String,
81    pub schema_digest: String,
82    pub modules: Vec<ModuleInventoryModule>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
86#[serde(rename_all = "camelCase", deny_unknown_fields)]
87pub struct ModuleInventoryModule {
88    pub module_id: String,
89    pub version: String,
90    pub release_digest: String,
91    pub manifest_digest: String,
92    pub delivery: ModuleInventoryDelivery,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub dependency_module_ids: Vec<String>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub routes: Vec<ModuleInventoryRoute>,
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub runtime_functions: Vec<String>,
99    pub runtime_status: ModuleRuntimeStatus,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub console_ui: Option<ModuleInventoryConsoleUi>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
105#[serde(rename_all = "snake_case")]
106pub enum ModuleInventoryDelivery {
107    Linked,
108    Service,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum ModuleRuntimeStatus {
114    Active,
115    Disabled,
116    Degraded,
117    Failed,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct ModuleInventoryRoute {
123    pub method: String,
124    pub path: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub capability: Option<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub struct ModuleInventoryConsoleUi {
132    pub format: String,
133    pub protocol_major: u32,
134    pub artifact_digest: String,
135    pub entry: String,
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub style_assets: Vec<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct ActionContributionResolutionRequest {
143    pub context: ManagedServiceContext,
144    pub slot: String,
145    pub slot_version: u32,
146    #[serde(default)]
147    pub slot_context: BTreeMap<String, Value>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
151#[serde(rename_all = "camelCase", deny_unknown_fields)]
152pub struct ActionContributionResolution {
153    pub protocol: String,
154    pub context: ManagedServiceContext,
155    pub slot: String,
156    pub slot_version: u32,
157    pub contributions: Vec<ResolvedActionContribution>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
161#[serde(rename_all = "camelCase", deny_unknown_fields)]
162pub struct ResolvedActionContribution {
163    pub contributing_module_id: String,
164    pub target: String,
165    pub target_version: u32,
166    pub label: String,
167    pub action: ConsoleContributionAction,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub icon: Option<String>,
170    #[serde(default)]
171    pub required_capabilities: Vec<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct ModuleConfigReadRequest {
177    pub context: ManagedServiceContext,
178    pub module_id: String,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub keys: Vec<String>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct ModuleConfigReadResponse {
186    pub protocol: String,
187    pub context: ManagedServiceContext,
188    pub module_id: String,
189    pub values: Vec<ModuleConfigValue>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct ModuleConfigValue {
195    pub key: String,
196    pub field_type: ModuleConfigFieldType,
197    pub scope: ModuleConfigScope,
198    pub mutability: ModuleConfigMutability,
199    pub activation: ModuleConfigActivation,
200    pub sensitive: bool,
201    pub present: bool,
202    /// Sensitive values are always omitted, including when present.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub value: Option<Value>,
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct ModuleConfigWriteRequest {
210    pub context: ManagedServiceContext,
211    pub module_id: String,
212    pub values: Vec<ModuleConfigWriteValue>,
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217pub struct ModuleConfigWriteValue {
218    pub key: String,
219    pub value: Value,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
223#[serde(rename_all = "camelCase", deny_unknown_fields)]
224pub struct ModuleConfigWriteResponse {
225    pub protocol: String,
226    pub operation_id: String,
227    pub context: ManagedServiceContext,
228    pub module_id: String,
229    pub target_revision_before: String,
230    pub target_revision_after: String,
231    pub authorization_digest: String,
232    pub evidence: Vec<ModuleConfigAuditEvidence>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
236#[serde(rename_all = "camelCase", deny_unknown_fields)]
237pub struct ModuleConfigAuditEvidence {
238    pub sequence: u64,
239    pub operation_id: String,
240    pub module_id: String,
241    pub key: String,
242    pub sensitive: bool,
243    pub old_value_digest: Option<String>,
244    pub new_value_digest: String,
245    pub recorded_at_unix_ms: u64,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
249#[serde(tag = "kind", content = "document", rename_all = "snake_case")]
250pub enum ModuleOperationsMessage {
251    InventoryRequest(ModuleInventoryRequest),
252    Inventory(ModuleInventorySnapshot),
253    ContributionsRequest(ActionContributionResolutionRequest),
254    Contributions(ActionContributionResolution),
255    ConfigReadRequest(ModuleConfigReadRequest),
256    ConfigRead(ModuleConfigReadResponse),
257    ConfigWriteRequest(ModuleConfigWriteRequest),
258    ConfigWrite(ModuleConfigWriteResponse),
259}
260
261#[must_use]
262pub fn module_operations_schema() -> Value {
263    let mut schema = serde_json::to_value(schemars::schema_for!(ModuleOperationsMessage))
264        .expect("Module Operations schema must serialize");
265    schema["$id"] = Value::String(
266        "https://contracts.lenso.local/system-plane/lenso.system-plane.module-operations.v1.schema.json"
267            .to_owned(),
268    );
269    schema["title"] = Value::String("Lenso Managed Service Module Operations".to_owned());
270    for definition in [
271        "ModuleInventorySnapshot",
272        "ActionContributionResolution",
273        "ModuleConfigReadResponse",
274        "ModuleConfigWriteResponse",
275    ] {
276        if let Some(properties) = schema["$defs"][definition]["properties"].as_object_mut() {
277            properties.insert(
278                "protocol".to_owned(),
279                json!({ "type": "string", "const": MODULE_OPERATIONS_PROTOCOL }),
280            );
281        }
282    }
283    schema
284}
285
286#[must_use]
287pub fn module_operations_schema_digest() -> String {
288    digest_json(&module_operations_schema()).expect("Module Operations schema is digestible")
289}