Skip to main content

aion_server/api/handlers/
deploy.rs

1//! Shared operator-deploy handlers used by both transports.
2//!
3//! Every operation authorizes via [`crate::deploy::DeployGuard`] before any
4//! handler logic runs, then maps engine outcomes onto the deploy wire
5//! contract: `deploy_denied` for authorization, `version_pinned` for
6//! route-active/pinned refusals, `invalid_input` for malformed archives and
7//! the same-hash-different-manifest tripwire, `not_found` for unknown
8//! `(type, version)`, and 503/`Unavailable` for drain/shutdown windows.
9//! Mutations emit one structured audit line and the deploy metrics.
10
11use 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/// Deploy failure classes the transports must render distinctly: 503 vs 413
23/// vs the regular wire-code mapping.
24#[derive(Debug)]
25pub enum DeployApiError {
26    /// The server is draining or the engine is shutting down (503/`Unavailable`).
27    Unavailable(WireError),
28    /// The uploaded archive exceeds `deploy.max_archive_bytes`, or its
29    /// contents inflate past `deploy.max_inflated_bytes`
30    /// (413/`InvalidArgument`).
31    ArchiveTooLarge(WireError),
32    /// Mapped wire failure rendered through the standard code tables.
33    Wire(WireError),
34}
35
36impl DeployApiError {
37    /// Borrow the carried wire error regardless of class.
38    #[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
46/// Handles a deploy archive upload (`LoadPackage` / `POST /deploy/packages`).
47///
48/// Idempotency is specified behavior: re-sending a resident archive succeeds
49/// with `freshly_loaded = false`, and `route_changed` reports whether the
50/// call re-pointed routing.
51///
52/// # Errors
53///
54/// Returns [`DeployApiError`] for authorization denials, drain/shutdown
55/// refusals, oversized or malformed archives, the manifest-mismatch
56/// tripwire, and engine failures.
57pub 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    // Network input: extraction is bounded by the operator's inflate ceiling
68    // so a DEFLATE bomb under the upload ceiling cannot exhaust memory.
69    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
144/// Handles the deploy read model (`ListVersions` / `GET /deploy/versions`).
145///
146/// Listing keeps serving during drain: it is the operator's view of a
147/// rollout, not new work admission.
148///
149/// # Errors
150///
151/// Returns [`DeployApiError`] for authorization denials and engine failures.
152pub 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
181/// Handles a route re-point (`RouteVersion` / `POST /deploy/route`).
182///
183/// # Errors
184///
185/// Returns [`DeployApiError`] for authorization denials, drain/shutdown
186/// refusals, malformed hashes, unknown versions, and engine failures.
187pub 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
232/// Handles a version unload (`UnloadVersion` / `POST /deploy/unload`).
233///
234/// # Errors
235///
236/// Returns [`DeployApiError`] for authorization denials, drain/shutdown
237/// refusals, malformed hashes, unknown versions, pinned/route-active
238/// refusals, and engine failures.
239pub 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
284/// Authorization plus drain gate shared by every deploy mutation.
285fn 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
303/// Records, logs, and wraps an authorization denial. Denied calls never
304/// reach the engine.
305fn 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
327/// Enforces the operator-configured archive ceiling, naming the config key.
328fn enforce_archive_ceiling(state: &ServerState, archive_len: usize) -> Result<(), DeployApiError> {
329    let Some(limit) = state.runtime_config().deploy.max_archive_bytes else {
330        // The deploy surface is only mounted when validation proved the
331        // ceiling present; reaching this is a wiring bug, never a caller
332        // error, and it must fail loudly rather than admit unbounded bodies.
333        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
347/// Resolves the operator-configured inflate ceiling for archive extraction.
348fn inflate_ceiling(state: &ServerState) -> Result<u64, DeployApiError> {
349    state
350        .runtime_config()
351        .deploy
352        .max_inflated_bytes
353        .ok_or_else(|| {
354            // The deploy surface is only mounted when validation proved the
355            // ceiling present; reaching this is a wiring bug, never a caller
356            // error, and it must fail loudly rather than extract unbounded.
357            DeployApiError::Wire(WireError::backend(DEPLOY_MAX_INFLATED_BYTES_REQUIRED))
358        })
359}
360
361/// Decodes a `(workflow_type, content_hash)` target, refusing malformed input.
362fn 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
399/// Maps an engine failure onto the deploy wire contract, emitting the audit
400/// line and refusal metrics.
401fn 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        // On the deploy path archive/validation/collision/registration
414        // failures are caller-correctable input problems, not backend faults.
415        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        // UnknownVersion -> not_found, VersionPinned/RouteActive ->
422        // version_pinned, ManifestMismatch -> invalid_input via the central
423        // ServerError mapping; refusal prose passes through verbatim.
424        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
445/// Records, logs, and wraps an adapter-level refusal (malformed or
446/// oversized/over-inflating input), pre-mapped onto its wire class.
447fn 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
473/// Stable refusal-class label for audit lines and the outcome metric.
474fn 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
489/// Counter + gauge updates for one applied mutation. The gauge is set from
490/// the post-operation listing for the affected workflow type (0 when the
491/// last version of a type was unloaded).
492fn 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}