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