Skip to main content

aion_server/api/
deploy_grpc.rs

1//! tonic `DeployService` adapter over the shared deploy handlers.
2//!
3//! Added to the existing gRPC listener only when `[deploy].enabled` is true;
4//! a disabled surface answers `Unimplemented` (tonic's unknown-service
5//! response), exposing no deploy code path at all.
6
7use aion_proto::{
8    ProtoRouteVersionRequest, ProtoUnloadVersionRequest,
9    generated::{self, deploy_service_server::DeployServiceServer},
10};
11use tonic::{Code, Request, Response, Status};
12
13use super::grpc::{caller_from_metadata, status_from_wire_error, status_with_code};
14use crate::api::handlers::deploy::{self, DeployApiError};
15use crate::config::DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED;
16use crate::{CallerIdentity, ServerState};
17
18const TRANSPORT: &str = "grpc";
19
20/// Proto-framing slack over the archive ceiling for the unary
21/// `LoadPackageRequest`: the message wraps the archive bytes in one
22/// length-delimited field (tag byte plus a length varint of at most ten
23/// bytes), so a conformant archive of exactly `max_archive_bytes` always
24/// decodes. This is slack, not enforcement: an archive between the limit
25/// and limit + 64 still decodes here and is then rejected exactly by the
26/// handler's `deploy.max_archive_bytes` check, which is authoritative. The
27/// transport bound only caps buffering at limit + 64.
28const LOAD_PACKAGE_FRAMING_ALLOWANCE: usize = 64;
29
30/// Cloneable tonic implementation of the operator deploy service.
31#[derive(Clone)]
32pub struct DeployGrpcService {
33    state: ServerState,
34}
35
36impl DeployGrpcService {
37    /// Build a tonic deploy service from shared server state.
38    #[must_use]
39    pub const fn new(state: ServerState) -> Self {
40        Self { state }
41    }
42
43    async fn caller<T>(&self, request: &Request<T>) -> Result<CallerIdentity, Status> {
44        caller_from_metadata(request.metadata(), &self.state).await
45    }
46}
47
48/// Construct the generated tonic server wrapper with a decode ceiling sized
49/// from `deploy.max_archive_bytes`.
50///
51/// # Errors
52///
53/// Returns [`crate::ServerError::Config`] when the deploy surface is enabled
54/// without the required `deploy.max_archive_bytes` (defense in depth; config
55/// validation refuses this earlier).
56pub fn deploy_service(
57    state: ServerState,
58) -> Result<DeployServiceServer<DeployGrpcService>, crate::ServerError> {
59    let Some(limit) = state.runtime_config().deploy.max_archive_bytes else {
60        return Err(crate::ServerError::Config {
61            message: DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED.to_owned(),
62        });
63    };
64    // Config validation guarantees the ceiling fits in usize on this
65    // platform; saturating is a defensive no-op kept over a panic path.
66    let limit = usize::try_from(limit).unwrap_or(usize::MAX);
67    Ok(DeployServiceServer::new(DeployGrpcService::new(state))
68        .max_decoding_message_size(limit.saturating_add(LOAD_PACKAGE_FRAMING_ALLOWANCE)))
69}
70
71#[tonic::async_trait]
72impl generated::deploy_service_server::DeployService for DeployGrpcService {
73    async fn load_package(
74        &self,
75        request: Request<generated::LoadPackageRequest>,
76    ) -> Result<Response<generated::LoadPackageResponse>, Status> {
77        let caller = self.caller(&request).await?;
78        let response = deploy::load_package(
79            &self.state,
80            &caller,
81            TRANSPORT,
82            request.into_inner().archive,
83        )
84        .await
85        .map_err(status_from_deploy_error)?;
86        Ok(Response::new(generated::LoadPackageResponse {
87            workflow_type: response.workflow_type,
88            content_hash: response.content_hash,
89            deployed_entry_module: response.deployed_entry_module,
90            entry_function: response.entry_function,
91            freshly_loaded: response.freshly_loaded,
92            route_changed: response.route_changed,
93            superseded_versions: response.superseded_versions,
94        }))
95    }
96
97    async fn list_versions(
98        &self,
99        request: Request<generated::ListVersionsRequest>,
100    ) -> Result<Response<generated::ListVersionsResponse>, Status> {
101        let caller = self.caller(&request).await?;
102        let response = deploy::list_versions(&self.state, &caller, TRANSPORT)
103            .map_err(status_from_deploy_error)?;
104        Ok(Response::new(generated::ListVersionsResponse {
105            versions: response
106                .versions
107                .into_iter()
108                .map(|version| generated::WorkflowVersion {
109                    workflow_type: version.workflow_type,
110                    content_hash: version.content_hash,
111                    deployed_entry_module: version.deployed_entry_module,
112                    entry_function: version.entry_function,
113                    manifest_version: version.manifest_version,
114                    loaded_at: version.loaded_at,
115                    route_active: version.route_active,
116                })
117                .collect(),
118        }))
119    }
120
121    async fn route_version(
122        &self,
123        request: Request<generated::RouteVersionRequest>,
124    ) -> Result<Response<generated::RouteVersionResponse>, Status> {
125        let caller = self.caller(&request).await?;
126        let inner = request.into_inner();
127        deploy::route_version(
128            &self.state,
129            &caller,
130            TRANSPORT,
131            ProtoRouteVersionRequest {
132                workflow_type: inner.workflow_type,
133                content_hash: inner.content_hash,
134            },
135        )
136        .await
137        .map_err(status_from_deploy_error)?;
138        Ok(Response::new(generated::RouteVersionResponse {}))
139    }
140
141    async fn unload_version(
142        &self,
143        request: Request<generated::UnloadVersionRequest>,
144    ) -> Result<Response<generated::UnloadVersionResponse>, Status> {
145        let caller = self.caller(&request).await?;
146        let inner = request.into_inner();
147        deploy::unload_version(
148            &self.state,
149            &caller,
150            TRANSPORT,
151            ProtoUnloadVersionRequest {
152                workflow_type: inner.workflow_type,
153                content_hash: inner.content_hash,
154            },
155        )
156        .await
157        .map_err(status_from_deploy_error)?;
158        Ok(Response::new(generated::UnloadVersionResponse {}))
159    }
160}
161
162/// Deploy failure mapping: drain/shutdown → `Unavailable`, oversized archive
163/// → `InvalidArgument`, everything else through the standard code table.
164/// The typed `ProtoWireError` detail rides every status.
165fn status_from_deploy_error(error: DeployApiError) -> Status {
166    match error {
167        DeployApiError::Unavailable(wire) => status_with_code(Code::Unavailable, wire),
168        DeployApiError::ArchiveTooLarge(wire) => status_with_code(Code::InvalidArgument, wire),
169        DeployApiError::Wire(wire) => status_from_wire_error(wire),
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use std::sync::Arc;
176
177    use aion::EngineBuilder;
178    use aion_proto::{ProtoWireError, WireError, WireErrorCode, generated};
179    use aion_store::{EventStore, InMemoryStore};
180    use prost::Message as _;
181    use tonic::{Code, Request, Status};
182
183    use super::DeployGrpcService;
184    use crate::config::{
185        AuthConfig, AuthoringConfig, DeployConfig, ListenConfig, MetricsConfig, NamespaceConfig,
186        NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig, WebSocketConfig,
187        WorkerConfig,
188    };
189    use crate::{
190        NamespaceResolver, ServerState, StaticScheduleNamespaces, StaticWorkflowNamespaces,
191    };
192
193    /// Decode the typed `ProtoWireError` detail riding a deploy status.
194    fn decode_detail(status: &Status) -> Result<WireError, Box<dyn std::error::Error>> {
195        let proto = ProtoWireError::decode(status.details())?;
196        Ok(WireError::try_from(proto)?)
197    }
198
199    fn runtime_config() -> RuntimeConfig {
200        RuntimeConfig {
201            listen: ListenConfig {
202                grpc: std::net::SocketAddr::from(([127, 0, 0, 1], 50051)),
203                http: std::net::SocketAddr::from(([127, 0, 0, 1], 8080)),
204            },
205            tls: None,
206            auth: AuthConfig {
207                enabled: false,
208                jwks_url: None,
209                jwks_refresh_seconds: 300,
210            },
211            ops_console: OpsConsoleConfig {
212                source: OpsConsoleAssetSource::Embedded,
213            },
214            namespace: NamespaceConfig {
215                mode: NamespaceMode::SharedEngine,
216            },
217            worker: WorkerConfig {
218                heartbeat_window: std::time::Duration::from_secs(30),
219                ..WorkerConfig::default()
220            },
221            websocket: WebSocketConfig {
222                outbound_buffer_bound: 32,
223                event_broadcast_capacity: Some(64),
224                cluster_broadcast_capacity: Some(64),
225            },
226            workflow_packages: Vec::new(),
227            deploy: DeployConfig::default(),
228            authoring: AuthoringConfig::default(),
229            dev: crate::config::DevConfig::default(),
230            outbox: crate::config::OutboxConfig::default(),
231            observability: crate::config::ObservabilityConfig::default(),
232            scheduler_threads: 1,
233            query_timeout: Some(std::time::Duration::from_secs(10)),
234            default_namespace: "default".to_owned(),
235            auto_create: crate::config::AutoCreate::Open,
236            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
237            drain_timeout: std::time::Duration::from_secs(30),
238            metrics: MetricsConfig { enabled: true },
239            owned_shards: Vec::new(),
240            cors_allowed_origins: Vec::new(),
241        }
242    }
243
244    async fn deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
245        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
246        let engine = Arc::new(
247            EngineBuilder::new()
248                .store_arc(store)
249                .in_memory_visibility()
250                .scheduler_threads(1)
251                .build()
252                .await?,
253        );
254        let resolver = NamespaceResolver::from_parts(
255            NamespaceMode::SharedEngine,
256            Some(engine),
257            Arc::new(StaticWorkflowNamespaces::default()),
258            Arc::new(StaticScheduleNamespaces::default()),
259        );
260        let mut config = runtime_config();
261        config.deploy = DeployConfig {
262            enabled: true,
263            max_archive_bytes: Some(1024),
264            max_inflated_bytes: Some(2048),
265        };
266        Ok(ServerState::from_parts(resolver, config))
267    }
268
269    /// Shared-secret bearer accepted by the dev-token path (`auth.enabled =
270    /// true`, `not(feature = "auth")`), wired as the configured `jwks_url`.
271    #[cfg(not(feature = "auth"))]
272    const AUTH_TOKEN: &str = "deploy-secret";
273
274    /// Deploy-enabled state with authentication ON via the dev-token path, used
275    /// to prove the strict gate still denies an ungranted caller.
276    #[cfg(not(feature = "auth"))]
277    async fn auth_on_deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
278        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
279        let engine = Arc::new(
280            EngineBuilder::new()
281                .store_arc(store)
282                .in_memory_visibility()
283                .scheduler_threads(1)
284                .build()
285                .await?,
286        );
287        let resolver = NamespaceResolver::from_parts(
288            NamespaceMode::SharedEngine,
289            Some(engine),
290            Arc::new(StaticWorkflowNamespaces::default()),
291            Arc::new(StaticScheduleNamespaces::default()),
292        );
293        let mut config = runtime_config();
294        config.auth = AuthConfig {
295            enabled: true,
296            jwks_url: Some(AUTH_TOKEN.to_owned()),
297            jwks_refresh_seconds: 300,
298        };
299        config.deploy = DeployConfig {
300            enabled: true,
301            max_archive_bytes: Some(1024),
302            max_inflated_bytes: Some(2048),
303        };
304        Ok(ServerState::from_parts(resolver, config))
305    }
306
307    fn granted_request<T>(message: T) -> Result<Request<T>, Box<dyn std::error::Error>> {
308        let mut request = Request::new(message);
309        request
310            .metadata_mut()
311            .insert("x-aion-subject", "ci".parse()?);
312        request
313            .metadata_mut()
314            .insert("x-aion-deploy", "true".parse()?);
315        Ok(request)
316    }
317
318    /// Auth-off single-tenant operator mode: the gRPC caller IS the operator
319    /// and holds the deploy grant decided server-side, so deploy is authorized
320    /// with no `x-aion-deploy` metadata at all (mirrors the HTTP boundary).
321    #[tokio::test]
322    async fn auth_off_operator_is_deploy_granted_without_metadata()
323    -> Result<(), Box<dyn std::error::Error>> {
324        use generated::deploy_service_server::DeployService as _;
325
326        let service = DeployGrpcService::new(deploy_state().await?);
327        let mut request = Request::new(generated::ListVersionsRequest {});
328        request
329            .metadata_mut()
330            .insert("x-aion-subject", "ci".parse()?);
331
332        let response = service.list_versions(request).await?;
333        assert!(response.into_inner().versions.is_empty());
334        Ok(())
335    }
336
337    /// Auth-ENABLED (dev-token path): the strict gate stays strict. A caller
338    /// with a valid bearer and subject but NO deploy grant is still denied —
339    /// operator mode never leaks into the auth-on path.
340    #[cfg(not(feature = "auth"))]
341    #[tokio::test]
342    async fn auth_on_denies_caller_without_deploy_grant() -> Result<(), Box<dyn std::error::Error>>
343    {
344        use generated::deploy_service_server::DeployService as _;
345
346        let service = DeployGrpcService::new(auth_on_deploy_state().await?);
347        let mut request = Request::new(generated::ListVersionsRequest {});
348        // Valid shared-secret bearer + subject, but no x-aion-deploy grant.
349        request
350            .metadata_mut()
351            .insert("authorization", format!("Bearer {AUTH_TOKEN}").parse()?);
352        request
353            .metadata_mut()
354            .insert("x-aion-subject", "ci".parse()?);
355
356        let status = service
357            .list_versions(request)
358            .await
359            .err()
360            .ok_or("expected denial")?;
361        assert_eq!(status.code(), Code::PermissionDenied);
362        let detail = decode_detail(&status)?;
363        assert_eq!(detail.code, WireErrorCode::DeployDenied);
364        assert!(
365            detail.message.contains("x-aion-deploy"),
366            "denial must hint the dev header: {}",
367            detail.message
368        );
369        Ok(())
370    }
371
372    #[tokio::test]
373    async fn granted_metadata_lists_versions() -> Result<(), Box<dyn std::error::Error>> {
374        use generated::deploy_service_server::DeployService as _;
375
376        let service = DeployGrpcService::new(deploy_state().await?);
377        let response = service
378            .list_versions(granted_request(generated::ListVersionsRequest {})?)
379            .await?;
380        assert!(response.into_inner().versions.is_empty());
381        Ok(())
382    }
383
384    #[tokio::test]
385    async fn oversized_archive_is_invalid_argument_naming_the_key()
386    -> Result<(), Box<dyn std::error::Error>> {
387        use generated::deploy_service_server::DeployService as _;
388
389        let service = DeployGrpcService::new(deploy_state().await?);
390        let status = service
391            .load_package(granted_request(generated::LoadPackageRequest {
392                archive: vec![0_u8; 2048],
393            })?)
394            .await
395            .err()
396            .ok_or("expected oversize refusal")?;
397
398        assert_eq!(status.code(), Code::InvalidArgument);
399        assert!(
400            status.message().contains("deploy.max_archive_bytes"),
401            "refusal must name the config key: {}",
402            status.message()
403        );
404        Ok(())
405    }
406
407    #[tokio::test]
408    async fn route_to_unknown_version_is_not_found() -> Result<(), Box<dyn std::error::Error>> {
409        use generated::deploy_service_server::DeployService as _;
410
411        let service = DeployGrpcService::new(deploy_state().await?);
412        let status = service
413            .route_version(granted_request(generated::RouteVersionRequest {
414                workflow_type: "order".to_owned(),
415                content_hash: "a".repeat(64),
416            })?)
417            .await
418            .err()
419            .ok_or("expected unknown-version refusal")?;
420
421        assert_eq!(status.code(), Code::NotFound);
422        let detail = decode_detail(&status)?;
423        assert_eq!(detail.code, WireErrorCode::NotFound);
424        assert_eq!(detail.error_type.as_deref(), Some("UnknownVersion"));
425        Ok(())
426    }
427
428    #[tokio::test]
429    async fn malformed_hash_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
430        use generated::deploy_service_server::DeployService as _;
431
432        let service = DeployGrpcService::new(deploy_state().await?);
433        let status = service
434            .unload_version(granted_request(generated::UnloadVersionRequest {
435                workflow_type: "order".to_owned(),
436                content_hash: "not-a-hash".to_owned(),
437            })?)
438            .await
439            .err()
440            .ok_or("expected malformed-hash refusal")?;
441
442        assert_eq!(status.code(), Code::InvalidArgument);
443        assert!(
444            status.message().contains("not-a-hash"),
445            "refusal must name the malformed hash: {}",
446            status.message()
447        );
448        Ok(())
449    }
450
451    /// Drain: mutations refuse with `Unavailable`; the versions read model
452    /// keeps serving (operators watch rollouts through it).
453    #[tokio::test]
454    async fn drain_refuses_mutations_but_serves_listing() -> Result<(), Box<dyn std::error::Error>>
455    {
456        use generated::deploy_service_server::DeployService as _;
457
458        let state = deploy_state().await?;
459        assert!(state.drain_state().begin());
460        let service = DeployGrpcService::new(state);
461
462        let status = service
463            .route_version(granted_request(generated::RouteVersionRequest {
464                workflow_type: "order".to_owned(),
465                content_hash: "a".repeat(64),
466            })?)
467            .await
468            .err()
469            .ok_or("expected drain refusal")?;
470        assert_eq!(status.code(), Code::Unavailable);
471        assert!(
472            status.message().contains("draining"),
473            "drain refusal must be explicit: {}",
474            status.message()
475        );
476
477        let listing = service
478            .list_versions(granted_request(generated::ListVersionsRequest {})?)
479            .await?;
480        assert!(listing.into_inner().versions.is_empty());
481        Ok(())
482    }
483}