1use aion::EngineError;
12use aion_package::{ContentHash, ExtractionLimits, Package, PackageError};
13use aion_proto::{
14 ProtoListVersionsResponse, ProtoLoadPackageResponse, ProtoRouteVersionRequest,
15 ProtoRouteVersionResponse, ProtoUnloadVersionRequest, ProtoUnloadVersionResponse,
16 ProtoWorkflowVersion, WireError,
17};
18
19use crate::config::{DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED, DEPLOY_MAX_INFLATED_BYTES_REQUIRED};
20use crate::{CallerIdentity, ServerState};
21
22#[derive(Debug)]
25pub enum DeployApiError {
26 Unavailable(WireError),
28 ArchiveTooLarge(WireError),
32 Wire(WireError),
34}
35
36impl DeployApiError {
37 #[must_use]
39 pub const fn wire(&self) -> &WireError {
40 match self {
41 Self::Unavailable(wire) | Self::ArchiveTooLarge(wire) | Self::Wire(wire) => wire,
42 }
43 }
44}
45
46pub async fn load_package(
58 state: &ServerState,
59 caller: &CallerIdentity,
60 transport: &'static str,
61 archive: Vec<u8>,
62) -> Result<ProtoLoadPackageResponse, DeployApiError> {
63 authorize_mutation(state, caller, transport, "deploy.load")?;
64 enforce_archive_ceiling(state, archive.len())?;
65 let inflate_ceiling = inflate_ceiling(state)?;
66
67 let package = match Package::load_from_bytes(
70 &archive,
71 ExtractionLimits::bounded(inflate_ceiling),
72 ) {
73 Ok(package) => package,
74 Err(PackageError::InflatedSizeExceeded { limit }) => {
75 let wire = WireError::invalid_input(format!(
76 "archive contents inflate past the deploy.max_inflated_bytes limit of {limit} bytes; raise deploy.max_inflated_bytes (or AION_DEPLOY_MAX_INFLATED_BYTES) if this package size is intended"
77 ))
78 .with_error_type("Package");
79 return Err(refused(
80 state,
81 caller,
82 transport,
83 "deploy.load",
84 None,
85 DeployApiError::ArchiveTooLarge(wire),
86 ));
87 }
88 Err(error) => {
89 let wire = WireError::invalid_input(format!("archive rejected: {error}"))
90 .with_error_type("Package");
91 return Err(refused(
92 state,
93 caller,
94 transport,
95 "deploy.load",
96 None,
97 DeployApiError::Wire(wire),
98 ));
99 }
100 };
101 let engine = engine_handle(state)?;
102 match engine.load_package(package).await {
103 Ok(outcome) => {
104 let workflow_type = outcome.record.workflow_type().to_owned();
105 let content_hash = outcome.record.version().to_string();
106 let audit_outcome = if outcome.freshly_loaded {
107 "loaded"
108 } else {
109 "idempotent"
110 };
111 tracing::info!(
112 operation = "deploy.load",
113 subject = caller.subject(),
114 grant_source = caller.grant_source().label(),
115 transport,
116 workflow_type = %workflow_type,
117 content_hash = %content_hash,
118 outcome = audit_outcome,
119 freshly_loaded = outcome.freshly_loaded,
120 route_changed = outcome.route_changed,
121 "deploy mutation applied"
122 );
123 record_mutation_metrics(state, "deploy.load", audit_outcome, &workflow_type);
124 Ok(ProtoLoadPackageResponse {
125 workflow_type,
126 content_hash,
127 deployed_entry_module: outcome.record.deployed_entry_module().to_owned(),
128 entry_function: outcome.record.entry_function().to_owned(),
129 freshly_loaded: outcome.freshly_loaded,
130 route_changed: outcome.route_changed,
131 superseded_versions: outcome
132 .superseded_versions
133 .iter()
134 .map(ToString::to_string)
135 .collect(),
136 })
137 }
138 Err(error) => Err(map_engine_refusal(
139 state,
140 caller,
141 transport,
142 "deploy.load",
143 None,
144 error,
145 )),
146 }
147}
148
149pub fn list_versions(
158 state: &ServerState,
159 caller: &CallerIdentity,
160 transport: &'static str,
161) -> Result<ProtoListVersionsResponse, DeployApiError> {
162 let guard = state.deploy_guard();
163 if let Err(error) = guard.authorize(caller) {
164 return Err(denied(state, caller, transport, "deploy.list", &error));
165 }
166 let engine = engine_handle(state)?;
167 let versions = engine
168 .list_workflow_versions()
169 .map_err(|error| DeployApiError::Wire(crate::ServerError::from(error).to_wire_error()))?;
170 Ok(ProtoListVersionsResponse {
171 versions: versions
172 .into_iter()
173 .map(|info| ProtoWorkflowVersion {
174 workflow_type: info.workflow_type,
175 content_hash: info.content_hash.to_string(),
176 deployed_entry_module: info.deployed_entry_module,
177 entry_function: info.entry_function,
178 manifest_version: info.manifest_version.as_str().to_owned(),
179 loaded_at: info.loaded_at.to_rfc3339(),
180 route_active: info.route_active,
181 })
182 .collect(),
183 })
184}
185
186pub async fn route_version(
193 state: &ServerState,
194 caller: &CallerIdentity,
195 transport: &'static str,
196 request: ProtoRouteVersionRequest,
197) -> Result<ProtoRouteVersionResponse, DeployApiError> {
198 authorize_mutation(state, caller, transport, "deploy.route")?;
199 let (workflow_type, version) = decode_version_target(
200 state,
201 caller,
202 transport,
203 "deploy.route",
204 &request.workflow_type,
205 &request.content_hash,
206 )?;
207 let engine = engine_handle(state)?;
208 match engine
209 .route_workflow_version(&workflow_type, &version)
210 .await
211 {
212 Ok(()) => {
213 tracing::info!(
214 operation = "deploy.route",
215 subject = caller.subject(),
216 grant_source = caller.grant_source().label(),
217 transport,
218 workflow_type = %workflow_type,
219 content_hash = %version,
220 outcome = "rerouted",
221 "deploy mutation applied"
222 );
223 record_mutation_metrics(state, "deploy.route", "rerouted", &workflow_type);
224 Ok(ProtoRouteVersionResponse {})
225 }
226 Err(error) => Err(map_engine_refusal(
227 state,
228 caller,
229 transport,
230 "deploy.route",
231 Some((&workflow_type, &version)),
232 error,
233 )),
234 }
235}
236
237pub async fn unload_version(
245 state: &ServerState,
246 caller: &CallerIdentity,
247 transport: &'static str,
248 request: ProtoUnloadVersionRequest,
249) -> Result<ProtoUnloadVersionResponse, DeployApiError> {
250 authorize_mutation(state, caller, transport, "deploy.unload")?;
251 let (workflow_type, version) = decode_version_target(
252 state,
253 caller,
254 transport,
255 "deploy.unload",
256 &request.workflow_type,
257 &request.content_hash,
258 )?;
259 let engine = engine_handle(state)?;
260 match engine
261 .unload_workflow_version(&workflow_type, &version)
262 .await
263 {
264 Ok(()) => {
265 tracing::info!(
266 operation = "deploy.unload",
267 subject = caller.subject(),
268 grant_source = caller.grant_source().label(),
269 transport,
270 workflow_type = %workflow_type,
271 content_hash = %version,
272 outcome = "unloaded",
273 "deploy mutation applied"
274 );
275 record_mutation_metrics(state, "deploy.unload", "unloaded", &workflow_type);
276 Ok(ProtoUnloadVersionResponse {})
277 }
278 Err(error) => Err(map_engine_refusal(
279 state,
280 caller,
281 transport,
282 "deploy.unload",
283 Some((&workflow_type, &version)),
284 error,
285 )),
286 }
287}
288
289fn authorize_mutation(
291 state: &ServerState,
292 caller: &CallerIdentity,
293 transport: &'static str,
294 operation: &'static str,
295) -> Result<(), DeployApiError> {
296 let guard = state.deploy_guard();
297 if let Err(error) = guard.authorize(caller) {
298 return Err(denied(state, caller, transport, operation, &error));
299 }
300 if state.drain_state().is_draining() {
301 return Err(DeployApiError::Unavailable(WireError::backend(
302 "server is draining and not accepting deploy mutations",
303 )));
304 }
305 Ok(())
306}
307
308fn denied(
311 state: &ServerState,
312 caller: &CallerIdentity,
313 transport: &'static str,
314 operation: &'static str,
315 error: &crate::ServerError,
316) -> DeployApiError {
317 let wire = error.to_wire_error();
318 tracing::warn!(
319 operation,
320 subject = caller.subject(),
321 grant_source = caller.grant_source().label(),
322 transport,
323 reason = %wire.message,
324 "deploy operation denied"
325 );
326 if let Some(metrics) = state.metrics() {
327 metrics.deploy_denied(transport);
328 }
329 DeployApiError::Wire(wire)
330}
331
332fn enforce_archive_ceiling(state: &ServerState, archive_len: usize) -> Result<(), DeployApiError> {
334 let Some(limit) = state.runtime_config().deploy.max_archive_bytes else {
335 return Err(DeployApiError::Wire(WireError::backend(
339 DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED,
340 )));
341 };
342 if archive_len as u64 > limit {
343 return Err(DeployApiError::ArchiveTooLarge(WireError::invalid_input(
344 format!(
345 "archive is {archive_len} bytes, exceeding the deploy.max_archive_bytes limit of {limit} bytes; raise deploy.max_archive_bytes (or AION_DEPLOY_MAX_ARCHIVE_BYTES) if this package size is intended"
346 ),
347 )));
348 }
349 Ok(())
350}
351
352fn inflate_ceiling(state: &ServerState) -> Result<u64, DeployApiError> {
354 state
355 .runtime_config()
356 .deploy
357 .max_inflated_bytes
358 .ok_or_else(|| {
359 DeployApiError::Wire(WireError::backend(DEPLOY_MAX_INFLATED_BYTES_REQUIRED))
363 })
364}
365
366fn decode_version_target(
368 state: &ServerState,
369 caller: &CallerIdentity,
370 transport: &'static str,
371 operation: &'static str,
372 workflow_type: &str,
373 content_hash: &str,
374) -> Result<(String, ContentHash), DeployApiError> {
375 if workflow_type.is_empty() {
376 let wire = WireError::invalid_input("workflow_type must not be empty");
377 return Err(refused(
378 state,
379 caller,
380 transport,
381 operation,
382 None,
383 DeployApiError::Wire(wire),
384 ));
385 }
386 match content_hash.parse::<ContentHash>() {
387 Ok(version) => Ok((workflow_type.to_owned(), version)),
388 Err(error) => {
389 let wire = WireError::invalid_input(format!(
390 "content_hash `{content_hash}` is not a canonical content hash: {error}"
391 ));
392 Err(refused(
393 state,
394 caller,
395 transport,
396 operation,
397 None,
398 DeployApiError::Wire(wire),
399 ))
400 }
401 }
402}
403
404fn map_engine_refusal(
407 state: &ServerState,
408 caller: &CallerIdentity,
409 transport: &'static str,
410 operation: &'static str,
411 target: Option<(&str, &ContentHash)>,
412 error: EngineError,
413) -> DeployApiError {
414 let mapped = match error {
415 EngineError::ShuttingDown => DeployApiError::Unavailable(
416 WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
417 ),
418 EngineError::Load { .. } => DeployApiError::Wire(
421 WireError::invalid_input(error.to_string()).with_error_type("Load"),
422 ),
423 EngineError::Package(_) => DeployApiError::Wire(
424 WireError::invalid_input(error.to_string()).with_error_type("Package"),
425 ),
426 other => DeployApiError::Wire(crate::ServerError::from(other).to_wire_error()),
430 };
431 let wire = mapped.wire();
432 let outcome = refusal_outcome(&mapped);
433 tracing::info!(
434 operation,
435 subject = caller.subject(),
436 grant_source = caller.grant_source().label(),
437 transport,
438 workflow_type = target.map(|(workflow_type, _)| workflow_type),
439 content_hash = target.map(|(_, version)| version.to_string()).as_deref(),
440 outcome,
441 reason = %wire.message,
442 "deploy mutation refused"
443 );
444 if let Some(metrics) = state.metrics() {
445 metrics.deploy_operation(operation, outcome);
446 }
447 mapped
448}
449
450fn refused(
453 state: &ServerState,
454 caller: &CallerIdentity,
455 transport: &'static str,
456 operation: &'static str,
457 target: Option<(&str, &ContentHash)>,
458 mapped: DeployApiError,
459) -> DeployApiError {
460 let outcome = refusal_outcome(&mapped);
461 tracing::info!(
462 operation,
463 subject = caller.subject(),
464 grant_source = caller.grant_source().label(),
465 transport,
466 workflow_type = target.map(|(workflow_type, _)| workflow_type),
467 content_hash = target.map(|(_, version)| version.to_string()).as_deref(),
468 outcome,
469 reason = %mapped.wire().message,
470 "deploy mutation refused"
471 );
472 if let Some(metrics) = state.metrics() {
473 metrics.deploy_operation(operation, outcome);
474 }
475 mapped
476}
477
478fn refusal_outcome(error: &DeployApiError) -> &'static str {
480 match error {
481 DeployApiError::Unavailable(_) => "unavailable",
482 DeployApiError::ArchiveTooLarge(_) | DeployApiError::Wire(_) => error.wire().code.as_str(),
483 }
484}
485
486fn engine_handle(state: &ServerState) -> Result<std::sync::Arc<aion::Engine>, DeployApiError> {
487 state
488 .deploy_guard()
489 .engine()
490 .map(std::sync::Arc::clone)
491 .map_err(|error| DeployApiError::Wire(error.to_wire_error()))
492}
493
494fn record_mutation_metrics(
498 state: &ServerState,
499 operation: &'static str,
500 outcome: &'static str,
501 workflow_type: &str,
502) {
503 let Some(metrics) = state.metrics() else {
504 return;
505 };
506 metrics.deploy_operation(operation, outcome);
507 let Ok(engine) = state.deploy_guard().engine().map(std::sync::Arc::clone) else {
508 return;
509 };
510 match engine.list_workflow_versions() {
511 Ok(versions) => {
512 let count = versions
513 .iter()
514 .filter(|info| info.workflow_type == workflow_type)
515 .count();
516 let count = i64::try_from(count).unwrap_or(i64::MAX);
517 metrics.set_loaded_workflow_versions(workflow_type, count);
518 }
519 Err(error) => {
520 tracing::warn!(
521 operation,
522 workflow_type,
523 %error,
524 "post-operation version listing failed; loaded-version gauge not updated"
525 );
526 }
527 }
528}