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 let awl = package.awl().cloned();
107 match engine.load_package(package).await {
108 Ok(outcome) => {
109 let workflow_type = outcome.record.workflow_type().to_owned();
110 let content_hash = outcome.record.version().to_string();
111 let audit_outcome = if outcome.freshly_loaded {
112 "loaded"
113 } else {
114 "idempotent"
115 };
116 tracing::info!(
117 operation = "deploy.load",
118 subject = caller.subject(),
119 grant_source = caller.grant_source().label(),
120 transport,
121 workflow_type = %workflow_type,
122 content_hash = %content_hash,
123 outcome = audit_outcome,
124 freshly_loaded = outcome.freshly_loaded,
125 route_changed = outcome.route_changed,
126 "deploy mutation applied"
127 );
128 record_mutation_metrics(state, "deploy.load", audit_outcome, &workflow_type);
129 let auto_workers = auto_provision(state, awl.as_ref(), &workflow_type).await;
130 Ok(ProtoLoadPackageResponse {
131 workflow_type,
132 content_hash,
133 deployed_entry_module: outcome.record.deployed_entry_module().to_owned(),
134 entry_function: outcome.record.entry_function().to_owned(),
135 freshly_loaded: outcome.freshly_loaded,
136 route_changed: outcome.route_changed,
137 superseded_versions: outcome
138 .superseded_versions
139 .iter()
140 .map(ToString::to_string)
141 .collect(),
142 auto_workers,
143 })
144 }
145 Err(error) => Err(map_engine_refusal(
146 state,
147 caller,
148 transport,
149 "deploy.load",
150 None,
151 error,
152 )),
153 }
154}
155
156pub fn list_versions(
165 state: &ServerState,
166 caller: &CallerIdentity,
167 transport: &'static str,
168) -> Result<ProtoListVersionsResponse, DeployApiError> {
169 let guard = state.deploy_guard();
170 if let Err(error) = guard.authorize(caller) {
171 return Err(denied(state, caller, transport, "deploy.list", &error));
172 }
173 let engine = engine_handle(state)?;
174 let versions = engine
175 .list_workflow_versions()
176 .map_err(|error| DeployApiError::Wire(crate::ServerError::from(error).to_wire_error()))?;
177 Ok(ProtoListVersionsResponse {
178 versions: versions
179 .into_iter()
180 .map(|info| ProtoWorkflowVersion {
181 workflow_type: info.workflow_type,
182 content_hash: info.content_hash.to_string(),
183 deployed_entry_module: info.deployed_entry_module,
184 entry_function: info.entry_function,
185 manifest_version: info.manifest_version.as_str().to_owned(),
186 loaded_at: info.loaded_at.to_rfc3339(),
187 route_active: info.route_active,
188 })
189 .collect(),
190 })
191}
192
193pub async fn route_version(
200 state: &ServerState,
201 caller: &CallerIdentity,
202 transport: &'static str,
203 request: ProtoRouteVersionRequest,
204) -> Result<ProtoRouteVersionResponse, DeployApiError> {
205 authorize_mutation(state, caller, transport, "deploy.route")?;
206 let (workflow_type, version) = decode_version_target(
207 state,
208 caller,
209 transport,
210 "deploy.route",
211 &request.workflow_type,
212 &request.content_hash,
213 )?;
214 let engine = engine_handle(state)?;
215 match engine
216 .route_workflow_version(&workflow_type, &version)
217 .await
218 {
219 Ok(()) => {
220 tracing::info!(
221 operation = "deploy.route",
222 subject = caller.subject(),
223 grant_source = caller.grant_source().label(),
224 transport,
225 workflow_type = %workflow_type,
226 content_hash = %version,
227 outcome = "rerouted",
228 "deploy mutation applied"
229 );
230 record_mutation_metrics(state, "deploy.route", "rerouted", &workflow_type);
231 Ok(ProtoRouteVersionResponse {})
232 }
233 Err(error) => Err(map_engine_refusal(
234 state,
235 caller,
236 transport,
237 "deploy.route",
238 Some((&workflow_type, &version)),
239 error,
240 )),
241 }
242}
243
244pub async fn unload_version(
252 state: &ServerState,
253 caller: &CallerIdentity,
254 transport: &'static str,
255 request: ProtoUnloadVersionRequest,
256) -> Result<ProtoUnloadVersionResponse, DeployApiError> {
257 authorize_mutation(state, caller, transport, "deploy.unload")?;
258 let (workflow_type, version) = decode_version_target(
259 state,
260 caller,
261 transport,
262 "deploy.unload",
263 &request.workflow_type,
264 &request.content_hash,
265 )?;
266 let engine = engine_handle(state)?;
267 match engine
268 .unload_workflow_version(&workflow_type, &version)
269 .await
270 {
271 Ok(()) => {
272 tracing::info!(
273 operation = "deploy.unload",
274 subject = caller.subject(),
275 grant_source = caller.grant_source().label(),
276 transport,
277 workflow_type = %workflow_type,
278 content_hash = %version,
279 outcome = "unloaded",
280 "deploy mutation applied"
281 );
282 record_mutation_metrics(state, "deploy.unload", "unloaded", &workflow_type);
283 Ok(ProtoUnloadVersionResponse {})
284 }
285 Err(error) => Err(map_engine_refusal(
286 state,
287 caller,
288 transport,
289 "deploy.unload",
290 Some((&workflow_type, &version)),
291 error,
292 )),
293 }
294}
295
296async fn auto_provision(
305 state: &ServerState,
306 awl: Option<&aion_package::AwlSource>,
307 workflow_type: &str,
308) -> Vec<aion_proto::ProtoAutoWorker> {
309 let Some(awl) = awl else {
310 return Vec::new();
311 };
312 let root = match crate::worker::auto_provision::document_root() {
313 Ok(root) => root,
314 Err(error) => {
315 tracing::error!(
319 operation = "worker.auto_provision",
320 workflow_type,
321 %error,
322 "no built-in agent worker could be provisioned for this document"
323 );
324 return vec![aion_proto::ProtoAutoWorker {
325 task_queue: String::new(),
326 decision: "failed".to_owned(),
327 deployment: String::new(),
328 detail: error.to_string(),
329 }];
330 }
331 };
332 crate::worker::auto_provision::provision(state, &root, awl, workflow_type)
333 .await
334 .into_iter()
335 .map(|outcome| aion_proto::ProtoAutoWorker {
336 task_queue: outcome.task_queue,
337 decision: outcome.decision.token().to_owned(),
338 deployment: outcome.deployment.unwrap_or_default(),
339 detail: outcome.detail,
340 })
341 .collect()
342}
343
344fn authorize_mutation(
346 state: &ServerState,
347 caller: &CallerIdentity,
348 transport: &'static str,
349 operation: &'static str,
350) -> Result<(), DeployApiError> {
351 let guard = state.deploy_guard();
352 if let Err(error) = guard.authorize(caller) {
353 return Err(denied(state, caller, transport, operation, &error));
354 }
355 if state.drain_state().is_draining() {
356 return Err(DeployApiError::Unavailable(WireError::backend(
357 "server is draining and not accepting deploy mutations",
358 )));
359 }
360 Ok(())
361}
362
363fn denied(
366 state: &ServerState,
367 caller: &CallerIdentity,
368 transport: &'static str,
369 operation: &'static str,
370 error: &crate::ServerError,
371) -> DeployApiError {
372 let wire = error.to_wire_error();
373 tracing::warn!(
374 operation,
375 subject = caller.subject(),
376 grant_source = caller.grant_source().label(),
377 transport,
378 reason = %wire.message,
379 "deploy operation denied"
380 );
381 if let Some(metrics) = state.metrics() {
382 metrics.deploy_denied(transport);
383 }
384 DeployApiError::Wire(wire)
385}
386
387fn enforce_archive_ceiling(state: &ServerState, archive_len: usize) -> Result<(), DeployApiError> {
389 let Some(limit) = state.runtime_config().deploy.max_archive_bytes else {
390 return Err(DeployApiError::Wire(WireError::backend(
394 DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED,
395 )));
396 };
397 if archive_len as u64 > limit {
398 return Err(DeployApiError::ArchiveTooLarge(WireError::invalid_input(
399 format!(
400 "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"
401 ),
402 )));
403 }
404 Ok(())
405}
406
407fn inflate_ceiling(state: &ServerState) -> Result<u64, DeployApiError> {
409 state
410 .runtime_config()
411 .deploy
412 .max_inflated_bytes
413 .ok_or_else(|| {
414 DeployApiError::Wire(WireError::backend(DEPLOY_MAX_INFLATED_BYTES_REQUIRED))
418 })
419}
420
421fn decode_version_target(
423 state: &ServerState,
424 caller: &CallerIdentity,
425 transport: &'static str,
426 operation: &'static str,
427 workflow_type: &str,
428 content_hash: &str,
429) -> Result<(String, ContentHash), DeployApiError> {
430 if workflow_type.is_empty() {
431 let wire = WireError::invalid_input("workflow_type must not be empty");
432 return Err(refused(
433 state,
434 caller,
435 transport,
436 operation,
437 None,
438 DeployApiError::Wire(wire),
439 ));
440 }
441 match content_hash.parse::<ContentHash>() {
442 Ok(version) => Ok((workflow_type.to_owned(), version)),
443 Err(error) => {
444 let wire = WireError::invalid_input(format!(
445 "content_hash `{content_hash}` is not a canonical content hash: {error}"
446 ));
447 Err(refused(
448 state,
449 caller,
450 transport,
451 operation,
452 None,
453 DeployApiError::Wire(wire),
454 ))
455 }
456 }
457}
458
459fn map_engine_refusal(
462 state: &ServerState,
463 caller: &CallerIdentity,
464 transport: &'static str,
465 operation: &'static str,
466 target: Option<(&str, &ContentHash)>,
467 error: EngineError,
468) -> DeployApiError {
469 let mapped = match error {
470 EngineError::ShuttingDown => DeployApiError::Unavailable(
471 WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
472 ),
473 EngineError::Load { .. } => DeployApiError::Wire(
476 WireError::invalid_input(error.to_string()).with_error_type("Load"),
477 ),
478 EngineError::Package(_) => DeployApiError::Wire(
479 WireError::invalid_input(error.to_string()).with_error_type("Package"),
480 ),
481 other => DeployApiError::Wire(crate::ServerError::from(other).to_wire_error()),
485 };
486 let wire = mapped.wire();
487 let outcome = refusal_outcome(&mapped);
488 tracing::info!(
489 operation,
490 subject = caller.subject(),
491 grant_source = caller.grant_source().label(),
492 transport,
493 workflow_type = target.map(|(workflow_type, _)| workflow_type),
494 content_hash = target.map(|(_, version)| version.to_string()).as_deref(),
495 outcome,
496 reason = %wire.message,
497 "deploy mutation refused"
498 );
499 if let Some(metrics) = state.metrics() {
500 metrics.deploy_operation(operation, outcome);
501 }
502 mapped
503}
504
505fn refused(
508 state: &ServerState,
509 caller: &CallerIdentity,
510 transport: &'static str,
511 operation: &'static str,
512 target: Option<(&str, &ContentHash)>,
513 mapped: DeployApiError,
514) -> DeployApiError {
515 let outcome = refusal_outcome(&mapped);
516 tracing::info!(
517 operation,
518 subject = caller.subject(),
519 grant_source = caller.grant_source().label(),
520 transport,
521 workflow_type = target.map(|(workflow_type, _)| workflow_type),
522 content_hash = target.map(|(_, version)| version.to_string()).as_deref(),
523 outcome,
524 reason = %mapped.wire().message,
525 "deploy mutation refused"
526 );
527 if let Some(metrics) = state.metrics() {
528 metrics.deploy_operation(operation, outcome);
529 }
530 mapped
531}
532
533fn refusal_outcome(error: &DeployApiError) -> &'static str {
535 match error {
536 DeployApiError::Unavailable(_) => "unavailable",
537 DeployApiError::ArchiveTooLarge(_) | DeployApiError::Wire(_) => error.wire().code.as_str(),
538 }
539}
540
541fn engine_handle(state: &ServerState) -> Result<std::sync::Arc<aion::Engine>, DeployApiError> {
542 state
543 .deploy_guard()
544 .engine()
545 .map(std::sync::Arc::clone)
546 .map_err(|error| DeployApiError::Wire(error.to_wire_error()))
547}
548
549fn record_mutation_metrics(
553 state: &ServerState,
554 operation: &'static str,
555 outcome: &'static str,
556 workflow_type: &str,
557) {
558 let Some(metrics) = state.metrics() else {
559 return;
560 };
561 metrics.deploy_operation(operation, outcome);
562 let Ok(engine) = state.deploy_guard().engine().map(std::sync::Arc::clone) else {
563 return;
564 };
565 match engine.list_workflow_versions() {
566 Ok(versions) => {
567 let count = versions
568 .iter()
569 .filter(|info| info.workflow_type == workflow_type)
570 .count();
571 let count = i64::try_from(count).unwrap_or(i64::MAX);
572 metrics.set_loaded_workflow_versions(workflow_type, count);
573 }
574 Err(error) => {
575 tracing::warn!(
576 operation,
577 workflow_type,
578 %error,
579 "post-operation version listing failed; loaded-version gauge not updated"
580 );
581 }
582 }
583}