Skip to main content

platform_module_management/
system_plane.rs

1//! Target-owned Service Installation capability for the independent System Plane.
2
3use axum::{
4    Extension, Json,
5    extract::{Path, Query},
6    http::StatusCode,
7};
8use lenso_module_management::{
9    ManagementActor, ServiceInstallationChange, ServiceInstallationError, ServiceInstallationPlan,
10    ServiceInstallationReceipt, ServiceInstallationSet, WorkspaceServiceInstallationManager,
11};
12use lenso_service::system_plane::CapabilityAdvertisement;
13use platform_system_plane::{
14    AuthorizedSystemPlaneCaller, SystemPlaneErrorBody, SystemPlaneRejection,
15};
16use schemars::schema_for;
17use serde::Deserialize;
18use serde_json::Value;
19use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
20use utoipa_axum::{router::OpenApiRouter, routes};
21
22pub const SERVICE_INSTALLATIONS_PROTOCOL: &str = "lenso.system-plane.service-installations.v1";
23pub const SERVICE_INSTALLATIONS_PATH: &str = "/system-plane/v1/service-installations";
24pub const SERVICE_INSTALLATIONS_FEATURE_SNAPSHOT: &str = "installation.snapshot";
25pub const SERVICE_INSTALLATIONS_FEATURE_PLAN: &str = "installation.plan";
26pub const SERVICE_INSTALLATIONS_FEATURE_APPLY: &str = "installation.apply";
27
28#[derive(Debug, Clone)]
29pub struct ServiceInstallationsProvider {
30    root: PathBuf,
31}
32
33impl ServiceInstallationsProvider {
34    #[must_use]
35    pub fn new(root: impl Into<PathBuf>) -> Self {
36        Self { root: root.into() }
37    }
38
39    #[must_use]
40    pub fn root(&self) -> &std::path::Path {
41        &self.root
42    }
43
44    #[must_use]
45    pub fn advertisement() -> CapabilityAdvertisement {
46        CapabilityAdvertisement {
47            contract_id: SERVICE_INSTALLATIONS_PROTOCOL.to_owned(),
48            major_version: 1,
49            feature_ids: BTreeSet::from([
50                SERVICE_INSTALLATIONS_FEATURE_APPLY.to_owned(),
51                SERVICE_INSTALLATIONS_FEATURE_PLAN.to_owned(),
52                SERVICE_INSTALLATIONS_FEATURE_SNAPSHOT.to_owned(),
53            ]),
54            schema_digest: service_installations_schema_digest(),
55            endpoint: SERVICE_INSTALLATIONS_PATH.to_owned(),
56        }
57    }
58
59    pub fn snapshot(
60        &self,
61        system_id: impl Into<String>,
62        environment_id: impl Into<String>,
63    ) -> Result<ServiceInstallationSet, ServiceInstallationError> {
64        WorkspaceServiceInstallationManager::new(&self.root, system_id, environment_id).snapshot()
65    }
66
67    pub fn preview(
68        &self,
69        system_id: impl Into<String>,
70        environment_id: impl Into<String>,
71        change: ServiceInstallationChange,
72        now: chrono::DateTime<chrono::Utc>,
73    ) -> Result<ServiceInstallationPlan, ServiceInstallationError> {
74        WorkspaceServiceInstallationManager::new(&self.root, system_id, environment_id)
75            .preview(change, now)
76    }
77
78    pub fn apply(
79        &self,
80        operation_id: &str,
81        plan: &ServiceInstallationPlan,
82        actor: &ManagementActor,
83        now: chrono::DateTime<chrono::Utc>,
84    ) -> Result<ServiceInstallationReceipt, ServiceInstallationError> {
85        WorkspaceServiceInstallationManager::new(&self.root, &plan.system_id, &plan.environment_id)
86            .apply(
87                operation_id,
88                plan,
89                &actor.actor_id,
90                &actor.verified_authorities,
91                now,
92            )
93    }
94}
95
96#[must_use]
97pub fn service_installations_schema_digest() -> String {
98    lenso_contracts::digest_json(&serde_json::json!({
99        "change": schema_for!(ServiceInstallationChange),
100        "plan": schema_for!(ServiceInstallationPlan),
101        "snapshot": schema_for!(lenso_module_management::ServiceInstallationSet),
102        "receipt": schema_for!(lenso_module_management::ServiceInstallationReceipt),
103    }))
104    .expect("Service Installation schemas are serializable")
105}
106
107#[derive(Debug, Deserialize)]
108#[serde(deny_unknown_fields)]
109struct InstallationScope {
110    system_id: String,
111}
112
113#[derive(Debug, Deserialize)]
114#[serde(deny_unknown_fields)]
115struct PreviewInstallationBody {
116    system_id: String,
117    environment_id: String,
118    change: ServiceInstallationChange,
119}
120
121#[derive(Debug, Deserialize)]
122#[serde(deny_unknown_fields)]
123struct ApplyInstallationBody {
124    operation_id: String,
125    plan: ServiceInstallationPlan,
126}
127
128#[must_use]
129pub fn system_plane_router<S>(
130    provider: Option<Arc<ServiceInstallationsProvider>>,
131) -> OpenApiRouter<S>
132where
133    S: Clone + Send + Sync + 'static,
134{
135    OpenApiRouter::new()
136        .routes(routes!(installation_snapshot))
137        .routes(routes!(preview_installation))
138        .routes(routes!(apply_installation))
139        .layer(Extension(provider))
140}
141
142#[utoipa::path(
143    get,
144    path = "/system-plane/v1/service-installations/{environment_id}",
145    params(("environment_id" = String, Path), ("system_id" = String, Query)),
146    responses(
147        (status = 200, description = "Target-owned desired Service Installation Set", body = Value),
148        (status = 401, description = "Workload Identity was rejected", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
149        (status = 403, description = "Enrollment does not grant snapshot access", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
150        (status = 503, description = "Service Installation state is unavailable", body = SystemPlaneErrorBody, content_type = "application/problem+json")
151    ),
152    security(("bearer_auth" = [])),
153    tag = "system-plane-service-installations"
154)]
155async fn installation_snapshot(
156    caller: AuthorizedSystemPlaneCaller,
157    Extension(provider): Extension<Option<Arc<ServiceInstallationsProvider>>>,
158    Path(environment_id): Path<String>,
159    Query(scope): Query<InstallationScope>,
160) -> Result<Json<Value>, SystemPlaneRejection> {
161    let provider = require_provider(provider)?;
162    require_feature(&caller, SERVICE_INSTALLATIONS_FEATURE_SNAPSHOT)?;
163    encode(
164        provider
165            .snapshot(scope.system_id, environment_id)
166            .map_err(map_error)?,
167    )
168}
169
170#[utoipa::path(
171    post,
172    path = "/system-plane/v1/service-installations/plans/preview",
173    request_body(content = Value, content_type = "application/json"),
174    responses(
175        (status = 200, description = "Immutable Service Installation Plan", body = Value),
176        (status = 400, description = "Installation change is invalid", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
177        (status = 401, description = "Workload Identity was rejected", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
178        (status = 403, description = "Enrollment does not grant planning access", body = SystemPlaneErrorBody, content_type = "application/problem+json")
179    ),
180    security(("bearer_auth" = [])),
181    tag = "system-plane-service-installations"
182)]
183async fn preview_installation(
184    caller: AuthorizedSystemPlaneCaller,
185    Extension(provider): Extension<Option<Arc<ServiceInstallationsProvider>>>,
186    Json(body): Json<Value>,
187) -> Result<Json<Value>, SystemPlaneRejection> {
188    let provider = require_provider(provider)?;
189    require_feature(&caller, SERVICE_INSTALLATIONS_FEATURE_PLAN)?;
190    let body: PreviewInstallationBody = serde_json::from_value(body).map_err(|error| {
191        invalid_request(format!("Service Installation change is invalid: {error}"))
192    })?;
193    encode(
194        provider
195            .preview(
196                body.system_id,
197                body.environment_id,
198                body.change,
199                chrono::Utc::now(),
200            )
201            .map_err(map_error)?,
202    )
203}
204
205#[utoipa::path(
206    post,
207    path = "/system-plane/v1/service-installations/plans/{plan_id}/apply",
208    params(("plan_id" = String, Path)),
209    request_body(content = Value, content_type = "application/json"),
210    responses(
211        (status = 200, description = "Durable Service Installation Receipt", body = Value),
212        (status = 400, description = "Installation plan is invalid", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
213        (status = 401, description = "Workload Identity was rejected", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
214        (status = 403, description = "Enrollment does not grant apply access", body = SystemPlaneErrorBody, content_type = "application/problem+json"),
215        (status = 409, description = "Installation state changed after preview", body = SystemPlaneErrorBody, content_type = "application/problem+json")
216    ),
217    security(("bearer_auth" = [])),
218    tag = "system-plane-service-installations"
219)]
220async fn apply_installation(
221    caller: AuthorizedSystemPlaneCaller,
222    Extension(provider): Extension<Option<Arc<ServiceInstallationsProvider>>>,
223    Path(plan_id): Path<String>,
224    Json(body): Json<Value>,
225) -> Result<Json<Value>, SystemPlaneRejection> {
226    let provider = require_provider(provider)?;
227    require_feature(&caller, SERVICE_INSTALLATIONS_FEATURE_APPLY)?;
228    let body: ApplyInstallationBody = serde_json::from_value(body).map_err(|error| {
229        invalid_request(format!("Service Installation plan is invalid: {error}"))
230    })?;
231    if body.plan.plan_id != plan_id {
232        return Err(invalid_request(
233            "Service Installation plan identity differs from request path",
234        ));
235    }
236    let actor = ManagementActor {
237        actor_id: format!("service:{}", caller.service_principal),
238        verified_authorities: BTreeSet::from(["service.manage".to_owned()]),
239    };
240    encode(
241        provider
242            .apply(&body.operation_id, &body.plan, &actor, chrono::Utc::now())
243            .map_err(map_error)?,
244    )
245}
246
247fn require_provider(
248    provider: Option<Arc<ServiceInstallationsProvider>>,
249) -> Result<Arc<ServiceInstallationsProvider>, SystemPlaneRejection> {
250    provider.ok_or_else(|| {
251        SystemPlaneRejection::unavailable(
252            "service_installations_unavailable",
253            "Service Installations capability is not configured for this Service",
254            "configure_service_installations",
255        )
256    })
257}
258
259fn require_feature(
260    caller: &AuthorizedSystemPlaneCaller,
261    feature: &str,
262) -> Result<(), SystemPlaneRejection> {
263    caller.require_capability(
264        SERVICE_INSTALLATIONS_PROTOCOL,
265        &service_installations_schema_digest(),
266        [feature],
267    )
268}
269
270fn encode(value: impl serde::Serialize) -> Result<Json<Value>, SystemPlaneRejection> {
271    serde_json::to_value(value).map(Json).map_err(|_| {
272        SystemPlaneRejection::unavailable(
273            "service_installations_serialization_failed",
274            "Service Installation result could not be encoded",
275            "inspect_service_installation_state",
276        )
277    })
278}
279
280fn invalid_request(message: impl Into<String>) -> SystemPlaneRejection {
281    SystemPlaneRejection::new(
282        StatusCode::BAD_REQUEST,
283        "service_installations_invalid_request",
284        message,
285        "correct_service_installation_request",
286    )
287}
288
289fn map_error(error: ServiceInstallationError) -> SystemPlaneRejection {
290    let (status, code, next_action) = match error {
291        ServiceInstallationError::InvalidContract(_)
292        | ServiceInstallationError::UnsafeOperationIdentity
293        | ServiceInstallationError::Json(_) => (
294            StatusCode::BAD_REQUEST,
295            "service_installations_invalid_request",
296            "correct_service_installation_request",
297        ),
298        ServiceInstallationError::MissingAuthority(_) => (
299            StatusCode::FORBIDDEN,
300            "service_installations_authority_required",
301            "review_service_enrollment_grant",
302        ),
303        ServiceInstallationError::StaleState => (
304            StatusCode::CONFLICT,
305            "service_installations_stale_state",
306            "preview_service_installation_again",
307        ),
308        ServiceInstallationError::Io(_) => (
309            StatusCode::SERVICE_UNAVAILABLE,
310            "service_installations_store_unavailable",
311            "restore_service_installation_store",
312        ),
313    };
314    SystemPlaneRejection::new(status, code, error.to_string(), next_action)
315}