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                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
149/// Handles the deploy read model (`ListVersions` / `GET /deploy/versions`).
150///
151/// Listing keeps serving during drain: it is the operator's view of a
152/// rollout, not new work admission.
153///
154/// # Errors
155///
156/// Returns [`DeployApiError`] for authorization denials and engine failures.
157pub 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
186/// Handles a route re-point (`RouteVersion` / `POST /deploy/route`).
187///
188/// # Errors
189///
190/// Returns [`DeployApiError`] for authorization denials, drain/shutdown
191/// refusals, malformed hashes, unknown versions, and engine failures.
192pub 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
237/// Handles a version unload (`UnloadVersion` / `POST /deploy/unload`).
238///
239/// # Errors
240///
241/// Returns [`DeployApiError`] for authorization denials, drain/shutdown
242/// refusals, malformed hashes, unknown versions, pinned/route-active
243/// refusals, and engine failures.
244pub 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
289/// Authorization plus drain gate shared by every deploy mutation.
290fn 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
308/// Records, logs, and wraps an authorization denial. Denied calls never
309/// reach the engine.
310fn 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
332/// Enforces the operator-configured archive ceiling, naming the config key.
333fn enforce_archive_ceiling(state: &ServerState, archive_len: usize) -> Result<(), DeployApiError> {
334    let Some(limit) = state.runtime_config().deploy.max_archive_bytes else {
335        // The deploy surface is only mounted when validation proved the
336        // ceiling present; reaching this is a wiring bug, never a caller
337        // error, and it must fail loudly rather than admit unbounded bodies.
338        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
352/// Resolves the operator-configured inflate ceiling for archive extraction.
353fn inflate_ceiling(state: &ServerState) -> Result<u64, DeployApiError> {
354    state
355        .runtime_config()
356        .deploy
357        .max_inflated_bytes
358        .ok_or_else(|| {
359            // The deploy surface is only mounted when validation proved the
360            // ceiling present; reaching this is a wiring bug, never a caller
361            // error, and it must fail loudly rather than extract unbounded.
362            DeployApiError::Wire(WireError::backend(DEPLOY_MAX_INFLATED_BYTES_REQUIRED))
363        })
364}
365
366/// Decodes a `(workflow_type, content_hash)` target, refusing malformed input.
367fn 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
404/// Maps an engine failure onto the deploy wire contract, emitting the audit
405/// line and refusal metrics.
406fn 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        // On the deploy path archive/validation/collision/registration
419        // failures are caller-correctable input problems, not backend faults.
420        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        // UnknownVersion -> not_found, VersionPinned/RouteActive ->
427        // version_pinned, ManifestMismatch -> invalid_input via the central
428        // ServerError mapping; refusal prose passes through verbatim.
429        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
450/// Records, logs, and wraps an adapter-level refusal (malformed or
451/// oversized/over-inflating input), pre-mapped onto its wire class.
452fn 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
478/// Stable refusal-class label for audit lines and the outcome metric.
479fn 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
494/// Counter + gauge updates for one applied mutation. The gauge is set from
495/// the post-operation listing for the affected workflow type (0 when the
496/// last version of a type was unloaded).
497fn 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}