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        }))
94    }
95
96    async fn list_versions(
97        &self,
98        request: Request<generated::ListVersionsRequest>,
99    ) -> Result<Response<generated::ListVersionsResponse>, Status> {
100        let caller = self.caller(&request).await?;
101        let response = deploy::list_versions(&self.state, &caller, TRANSPORT)
102            .map_err(status_from_deploy_error)?;
103        Ok(Response::new(generated::ListVersionsResponse {
104            versions: response
105                .versions
106                .into_iter()
107                .map(|version| generated::WorkflowVersion {
108                    workflow_type: version.workflow_type,
109                    content_hash: version.content_hash,
110                    deployed_entry_module: version.deployed_entry_module,
111                    entry_function: version.entry_function,
112                    manifest_version: version.manifest_version,
113                    loaded_at: version.loaded_at,
114                    route_active: version.route_active,
115                })
116                .collect(),
117        }))
118    }
119
120    async fn route_version(
121        &self,
122        request: Request<generated::RouteVersionRequest>,
123    ) -> Result<Response<generated::RouteVersionResponse>, Status> {
124        let caller = self.caller(&request).await?;
125        let inner = request.into_inner();
126        deploy::route_version(
127            &self.state,
128            &caller,
129            TRANSPORT,
130            ProtoRouteVersionRequest {
131                workflow_type: inner.workflow_type,
132                content_hash: inner.content_hash,
133            },
134        )
135        .await
136        .map_err(status_from_deploy_error)?;
137        Ok(Response::new(generated::RouteVersionResponse {}))
138    }
139
140    async fn unload_version(
141        &self,
142        request: Request<generated::UnloadVersionRequest>,
143    ) -> Result<Response<generated::UnloadVersionResponse>, Status> {
144        let caller = self.caller(&request).await?;
145        let inner = request.into_inner();
146        deploy::unload_version(
147            &self.state,
148            &caller,
149            TRANSPORT,
150            ProtoUnloadVersionRequest {
151                workflow_type: inner.workflow_type,
152                content_hash: inner.content_hash,
153            },
154        )
155        .await
156        .map_err(status_from_deploy_error)?;
157        Ok(Response::new(generated::UnloadVersionResponse {}))
158    }
159}
160
161/// Deploy failure mapping: drain/shutdown → `Unavailable`, oversized archive
162/// → `InvalidArgument`, everything else through the standard code table.
163/// The typed `ProtoWireError` detail rides every status.
164fn status_from_deploy_error(error: DeployApiError) -> Status {
165    match error {
166        DeployApiError::Unavailable(wire) => status_with_code(Code::Unavailable, wire),
167        DeployApiError::ArchiveTooLarge(wire) => status_with_code(Code::InvalidArgument, wire),
168        DeployApiError::Wire(wire) => status_from_wire_error(wire),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use std::sync::Arc;
175
176    use aion::EngineBuilder;
177    use aion_proto::{ProtoWireError, WireError, WireErrorCode, generated};
178    use aion_store::{EventStore, InMemoryStore};
179    use prost::Message as _;
180    use tonic::{Code, Request, Status};
181
182    use super::DeployGrpcService;
183    use crate::config::{
184        AuthConfig, AuthoringConfig, DashboardAssetSource, DashboardConfig, DeployConfig,
185        ListenConfig, MetricsConfig, NamespaceConfig, NamespaceMode, RuntimeConfig,
186        WebSocketConfig, WorkerConfig,
187    };
188    use crate::{
189        NamespaceResolver, ServerState, StaticScheduleNamespaces, StaticWorkflowNamespaces,
190    };
191
192    /// Decode the typed `ProtoWireError` detail riding a deploy status.
193    fn decode_detail(status: &Status) -> Result<WireError, Box<dyn std::error::Error>> {
194        let proto = ProtoWireError::decode(status.details())?;
195        Ok(WireError::try_from(proto)?)
196    }
197
198    fn runtime_config() -> RuntimeConfig {
199        RuntimeConfig {
200            listen: ListenConfig {
201                grpc: std::net::SocketAddr::from(([127, 0, 0, 1], 50051)),
202                http: std::net::SocketAddr::from(([127, 0, 0, 1], 8080)),
203            },
204            tls: None,
205            auth: AuthConfig {
206                enabled: false,
207                jwks_url: None,
208                jwks_refresh_seconds: 300,
209            },
210            dashboard: DashboardConfig {
211                source: DashboardAssetSource::Embedded,
212            },
213            namespace: NamespaceConfig {
214                mode: NamespaceMode::SharedEngine,
215            },
216            worker: WorkerConfig {
217                heartbeat_window: std::time::Duration::from_millis(30_000),
218            },
219            websocket: WebSocketConfig {
220                outbound_buffer_bound: 32,
221                event_broadcast_capacity: Some(64),
222            },
223            workflow_packages: Vec::new(),
224            deploy: DeployConfig::default(),
225            authoring: AuthoringConfig::default(),
226            dev: crate::config::DevConfig::default(),
227            outbox: crate::config::OutboxConfig::default(),
228            scheduler_threads: 1,
229            query_timeout: Some(std::time::Duration::from_millis(10_000)),
230            default_namespace: "default".to_owned(),
231            drain_timeout: std::time::Duration::from_secs(30),
232            metrics: MetricsConfig { enabled: true },
233            owned_shards: Vec::new(),
234            cors_allowed_origins: Vec::new(),
235        }
236    }
237
238    async fn deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
239        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
240        let engine = Arc::new(
241            EngineBuilder::new()
242                .store_arc(store)
243                .in_memory_visibility()
244                .scheduler_threads(1)
245                .build()
246                .await?,
247        );
248        let resolver = NamespaceResolver::from_parts(
249            NamespaceMode::SharedEngine,
250            Some(engine),
251            Arc::new(StaticWorkflowNamespaces::default()),
252            Arc::new(StaticScheduleNamespaces::default()),
253        );
254        let mut config = runtime_config();
255        config.deploy = DeployConfig {
256            enabled: true,
257            max_archive_bytes: Some(1024),
258            max_inflated_bytes: Some(2048),
259        };
260        Ok(ServerState::from_parts(resolver, config))
261    }
262
263    fn granted_request<T>(message: T) -> Result<Request<T>, Box<dyn std::error::Error>> {
264        let mut request = Request::new(message);
265        request
266            .metadata_mut()
267            .insert("x-aion-subject", "ci".parse()?);
268        request
269            .metadata_mut()
270            .insert("x-aion-deploy", "true".parse()?);
271        Ok(request)
272    }
273
274    #[tokio::test]
275    async fn denied_metadata_is_permission_denied_with_deploy_denied_detail()
276    -> Result<(), Box<dyn std::error::Error>> {
277        use generated::deploy_service_server::DeployService as _;
278
279        let service = DeployGrpcService::new(deploy_state().await?);
280        let mut request = Request::new(generated::ListVersionsRequest {});
281        request
282            .metadata_mut()
283            .insert("x-aion-subject", "ci".parse()?);
284
285        let status = service
286            .list_versions(request)
287            .await
288            .err()
289            .ok_or("expected denial")?;
290        assert_eq!(status.code(), Code::PermissionDenied);
291        let detail = decode_detail(&status)?;
292        assert_eq!(detail.code, WireErrorCode::DeployDenied);
293        assert!(
294            detail.message.contains("x-aion-deploy"),
295            "denial must hint the dev header: {}",
296            detail.message
297        );
298        Ok(())
299    }
300
301    #[tokio::test]
302    async fn granted_metadata_lists_versions() -> Result<(), Box<dyn std::error::Error>> {
303        use generated::deploy_service_server::DeployService as _;
304
305        let service = DeployGrpcService::new(deploy_state().await?);
306        let response = service
307            .list_versions(granted_request(generated::ListVersionsRequest {})?)
308            .await?;
309        assert!(response.into_inner().versions.is_empty());
310        Ok(())
311    }
312
313    #[tokio::test]
314    async fn oversized_archive_is_invalid_argument_naming_the_key()
315    -> Result<(), Box<dyn std::error::Error>> {
316        use generated::deploy_service_server::DeployService as _;
317
318        let service = DeployGrpcService::new(deploy_state().await?);
319        let status = service
320            .load_package(granted_request(generated::LoadPackageRequest {
321                archive: vec![0_u8; 2048],
322            })?)
323            .await
324            .err()
325            .ok_or("expected oversize refusal")?;
326
327        assert_eq!(status.code(), Code::InvalidArgument);
328        assert!(
329            status.message().contains("deploy.max_archive_bytes"),
330            "refusal must name the config key: {}",
331            status.message()
332        );
333        Ok(())
334    }
335
336    #[tokio::test]
337    async fn route_to_unknown_version_is_not_found() -> Result<(), Box<dyn std::error::Error>> {
338        use generated::deploy_service_server::DeployService as _;
339
340        let service = DeployGrpcService::new(deploy_state().await?);
341        let status = service
342            .route_version(granted_request(generated::RouteVersionRequest {
343                workflow_type: "order".to_owned(),
344                content_hash: "a".repeat(64),
345            })?)
346            .await
347            .err()
348            .ok_or("expected unknown-version refusal")?;
349
350        assert_eq!(status.code(), Code::NotFound);
351        let detail = decode_detail(&status)?;
352        assert_eq!(detail.code, WireErrorCode::NotFound);
353        assert_eq!(detail.error_type.as_deref(), Some("UnknownVersion"));
354        Ok(())
355    }
356
357    #[tokio::test]
358    async fn malformed_hash_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
359        use generated::deploy_service_server::DeployService as _;
360
361        let service = DeployGrpcService::new(deploy_state().await?);
362        let status = service
363            .unload_version(granted_request(generated::UnloadVersionRequest {
364                workflow_type: "order".to_owned(),
365                content_hash: "not-a-hash".to_owned(),
366            })?)
367            .await
368            .err()
369            .ok_or("expected malformed-hash refusal")?;
370
371        assert_eq!(status.code(), Code::InvalidArgument);
372        assert!(
373            status.message().contains("not-a-hash"),
374            "refusal must name the malformed hash: {}",
375            status.message()
376        );
377        Ok(())
378    }
379
380    /// Drain: mutations refuse with `Unavailable`; the versions read model
381    /// keeps serving (operators watch rollouts through it).
382    #[tokio::test]
383    async fn drain_refuses_mutations_but_serves_listing() -> Result<(), Box<dyn std::error::Error>>
384    {
385        use generated::deploy_service_server::DeployService as _;
386
387        let state = deploy_state().await?;
388        assert!(state.drain_state().begin());
389        let service = DeployGrpcService::new(state);
390
391        let status = service
392            .route_version(granted_request(generated::RouteVersionRequest {
393                workflow_type: "order".to_owned(),
394                content_hash: "a".repeat(64),
395            })?)
396            .await
397            .err()
398            .ok_or("expected drain refusal")?;
399        assert_eq!(status.code(), Code::Unavailable);
400        assert!(
401            status.message().contains("draining"),
402            "drain refusal must be explicit: {}",
403            status.message()
404        );
405
406        let listing = service
407            .list_versions(granted_request(generated::ListVersionsRequest {})?)
408            .await?;
409        assert!(listing.into_inner().versions.is_empty());
410        Ok(())
411    }
412}