1use 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}