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 })
132 }
133 Err(error) => Err(map_engine_refusal(
134 state,
135 caller,
136 transport,
137 "deploy.load",
138 None,
139 error,
140 )),
141 }
142}
143
144pub fn list_versions(
153 state: &ServerState,
154 caller: &CallerIdentity,
155 transport: &'static str,
156) -> Result<ProtoListVersionsResponse, DeployApiError> {
157 let guard = state.deploy_guard();
158 if let Err(error) = guard.authorize(caller) {
159 return Err(denied(state, caller, transport, "deploy.list", &error));
160 }
161 let engine = engine_handle(state)?;
162 let versions = engine
163 .list_workflow_versions()
164 .map_err(|error| DeployApiError::Wire(crate::ServerError::from(error).to_wire_error()))?;
165 Ok(ProtoListVersionsResponse {
166 versions: versions
167 .into_iter()
168 .map(|info| ProtoWorkflowVersion {
169 workflow_type: info.workflow_type,
170 content_hash: info.content_hash.to_string(),
171 deployed_entry_module: info.deployed_entry_module,
172 entry_function: info.entry_function,
173 manifest_version: info.manifest_version.as_str().to_owned(),
174 loaded_at: info.loaded_at.to_rfc3339(),
175 route_active: info.route_active,
176 })
177 .collect(),
178 })
179}
180
181pub async fn route_version(
188 state: &ServerState,
189 caller: &CallerIdentity,
190 transport: &'static str,
191 request: ProtoRouteVersionRequest,
192) -> Result<ProtoRouteVersionResponse, DeployApiError> {
193 authorize_mutation(state, caller, transport, "deploy.route")?;
194 let (workflow_type, version) = decode_version_target(
195 state,
196 caller,
197 transport,
198 "deploy.route",
199 &request.workflow_type,
200 &request.content_hash,
201 )?;
202 let engine = engine_handle(state)?;
203 match engine
204 .route_workflow_version(&workflow_type, &version)
205 .await
206 {
207 Ok(()) => {
208 tracing::info!(
209 operation = "deploy.route",
210 subject = caller.subject(),
211 grant_source = caller.grant_source().label(),
212 transport,
213 workflow_type = %workflow_type,
214 content_hash = %version,
215 outcome = "rerouted",
216 "deploy mutation applied"
217 );
218 record_mutation_metrics(state, "deploy.route", "rerouted", &workflow_type);
219 Ok(ProtoRouteVersionResponse {})
220 }
221 Err(error) => Err(map_engine_refusal(
222 state,
223 caller,
224 transport,
225 "deploy.route",
226 Some((&workflow_type, &version)),
227 error,
228 )),
229 }
230}
231
232pub async fn unload_version(
240 state: &ServerState,
241 caller: &CallerIdentity,
242 transport: &'static str,
243 request: ProtoUnloadVersionRequest,
244) -> Result<ProtoUnloadVersionResponse, DeployApiError> {
245 authorize_mutation(state, caller, transport, "deploy.unload")?;
246 let (workflow_type, version) = decode_version_target(
247 state,
248 caller,
249 transport,
250 "deploy.unload",
251 &request.workflow_type,
252 &request.content_hash,
253 )?;
254 let engine = engine_handle(state)?;
255 match engine
256 .unload_workflow_version(&workflow_type, &version)
257 .await
258 {
259 Ok(()) => {
260 tracing::info!(
261 operation = "deploy.unload",
262 subject = caller.subject(),
263 grant_source = caller.grant_source().label(),
264 transport,
265 workflow_type = %workflow_type,
266 content_hash = %version,
267 outcome = "unloaded",
268 "deploy mutation applied"
269 );
270 record_mutation_metrics(state, "deploy.unload", "unloaded", &workflow_type);
271 Ok(ProtoUnloadVersionResponse {})
272 }
273 Err(error) => Err(map_engine_refusal(
274 state,
275 caller,
276 transport,
277 "deploy.unload",
278 Some((&workflow_type, &version)),
279 error,
280 )),
281 }
282}
283
284fn authorize_mutation(
286 state: &ServerState,
287 caller: &CallerIdentity,
288 transport: &'static str,
289 operation: &'static str,
290) -> Result<(), DeployApiError> {
291 let guard = state.deploy_guard();
292 if let Err(error) = guard.authorize(caller) {
293 return Err(denied(state, caller, transport, operation, &error));
294 }
295 if state.drain_state().is_draining() {
296 return Err(DeployApiError::Unavailable(WireError::backend(
297 "server is draining and not accepting deploy mutations",
298 )));
299 }
300 Ok(())
301}
302
303fn denied(
306 state: &ServerState,
307 caller: &CallerIdentity,
308 transport: &'static str,
309 operation: &'static str,
310 error: &crate::ServerError,
311) -> DeployApiError {
312 let wire = error.to_wire_error();
313 tracing::warn!(
314 operation,
315 subject = caller.subject(),
316 grant_source = caller.grant_source().label(),
317 transport,
318 reason = %wire.message,
319 "deploy operation denied"
320 );
321 if let Some(metrics) = state.metrics() {
322 metrics.deploy_denied(transport);
323 }
324 DeployApiError::Wire(wire)
325}
326
327fn enforce_archive_ceiling(state: &ServerState, archive_len: usize) -> Result<(), DeployApiError> {
329 let Some(limit) = state.runtime_config().deploy.max_archive_bytes else {
330 return Err(DeployApiError::Wire(WireError::backend(
334 DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED,
335 )));
336 };
337 if archive_len as u64 > limit {
338 return Err(DeployApiError::ArchiveTooLarge(WireError::invalid_input(
339 format!(
340 "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"
341 ),
342 )));
343 }
344 Ok(())
345}
346
347fn inflate_ceiling(state: &ServerState) -> Result<u64, DeployApiError> {
349 state
350 .runtime_config()
351 .deploy
352 .max_inflated_bytes
353 .ok_or_else(|| {
354 DeployApiError::Wire(WireError::backend(DEPLOY_MAX_INFLATED_BYTES_REQUIRED))
358 })
359}
360
361fn decode_version_target(
363 state: &ServerState,
364 caller: &CallerIdentity,
365 transport: &'static str,
366 operation: &'static str,
367 workflow_type: &str,
368 content_hash: &str,
369) -> Result<(String, ContentHash), DeployApiError> {
370 if workflow_type.is_empty() {
371 let wire = WireError::invalid_input("workflow_type must not be empty");
372 return Err(refused(
373 state,
374 caller,
375 transport,
376 operation,
377 None,
378 DeployApiError::Wire(wire),
379 ));
380 }
381 match content_hash.parse::<ContentHash>() {
382 Ok(version) => Ok((workflow_type.to_owned(), version)),
383 Err(error) => {
384 let wire = WireError::invalid_input(format!(
385 "content_hash `{content_hash}` is not a canonical content hash: {error}"
386 ));
387 Err(refused(
388 state,
389 caller,
390 transport,
391 operation,
392 None,
393 DeployApiError::Wire(wire),
394 ))
395 }
396 }
397}
398
399fn map_engine_refusal(
402 state: &ServerState,
403 caller: &CallerIdentity,
404 transport: &'static str,
405 operation: &'static str,
406 target: Option<(&str, &ContentHash)>,
407 error: EngineError,
408) -> DeployApiError {
409 let mapped = match error {
410 EngineError::ShuttingDown => DeployApiError::Unavailable(
411 WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
412 ),
413 EngineError::Load { .. } => DeployApiError::Wire(
416 WireError::invalid_input(error.to_string()).with_error_type("Load"),
417 ),
418 EngineError::Package(_) => DeployApiError::Wire(
419 WireError::invalid_input(error.to_string()).with_error_type("Package"),
420 ),
421 other => DeployApiError::Wire(crate::ServerError::from(other).to_wire_error()),
425 };
426 let wire = mapped.wire();
427 let outcome = refusal_outcome(&mapped);
428 tracing::info!(
429 operation,
430 subject = caller.subject(),
431 grant_source = caller.grant_source().label(),
432 transport,
433 workflow_type = target.map(|(workflow_type, _)| workflow_type),
434 content_hash = target.map(|(_, version)| version.to_string()).as_deref(),
435 outcome,
436 reason = %wire.message,
437 "deploy mutation refused"
438 );
439 if let Some(metrics) = state.metrics() {
440 metrics.deploy_operation(operation, outcome);
441 }
442 mapped
443}
444
445fn refused(
448 state: &ServerState,
449 caller: &CallerIdentity,
450 transport: &'static str,
451 operation: &'static str,
452 target: Option<(&str, &ContentHash)>,
453 mapped: DeployApiError,
454) -> DeployApiError {
455 let outcome = refusal_outcome(&mapped);
456 tracing::info!(
457 operation,
458 subject = caller.subject(),
459 grant_source = caller.grant_source().label(),
460 transport,
461 workflow_type = target.map(|(workflow_type, _)| workflow_type),
462 content_hash = target.map(|(_, version)| version.to_string()).as_deref(),
463 outcome,
464 reason = %mapped.wire().message,
465 "deploy mutation refused"
466 );
467 if let Some(metrics) = state.metrics() {
468 metrics.deploy_operation(operation, outcome);
469 }
470 mapped
471}
472
473fn refusal_outcome(error: &DeployApiError) -> &'static str {
475 match error {
476 DeployApiError::Unavailable(_) => "unavailable",
477 DeployApiError::ArchiveTooLarge(_) | DeployApiError::Wire(_) => error.wire().code.as_str(),
478 }
479}
480
481fn engine_handle(state: &ServerState) -> Result<std::sync::Arc<aion::Engine>, DeployApiError> {
482 state
483 .deploy_guard()
484 .engine()
485 .map(std::sync::Arc::clone)
486 .map_err(|error| DeployApiError::Wire(error.to_wire_error()))
487}
488
489fn record_mutation_metrics(
493 state: &ServerState,
494 operation: &'static str,
495 outcome: &'static str,
496 workflow_type: &str,
497) {
498 let Some(metrics) = state.metrics() else {
499 return;
500 };
501 metrics.deploy_operation(operation, outcome);
502 let Ok(engine) = state.deploy_guard().engine().map(std::sync::Arc::clone) else {
503 return;
504 };
505 match engine.list_workflow_versions() {
506 Ok(versions) => {
507 let count = versions
508 .iter()
509 .filter(|info| info.workflow_type == workflow_type)
510 .count();
511 let count = i64::try_from(count).unwrap_or(i64::MAX);
512 metrics.set_loaded_workflow_versions(workflow_type, count);
513 }
514 Err(error) => {
515 tracing::warn!(
516 operation,
517 workflow_type,
518 %error,
519 "post-operation version listing failed; loaded-version gauge not updated"
520 );
521 }
522 }
523}