1use 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
22const LOAD_PACKAGE_FRAMING_ALLOWANCE: usize = 64;
31
32#[derive(Clone)]
34pub struct DeployGrpcService {
35 state: ServerState,
36}
37
38impl DeployGrpcService {
39 #[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
50pub 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 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
235fn 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
250fn status_from_supervision_error(error: &SupervisionError) -> Status {
253 status_from_wire_error(managed_workers::wire_error(error))
254}
255
256fn 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 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 default_namespace: "default".to_owned(),
331 auto_create: crate::config::AutoCreate::Open,
332 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
333 drain_timeout: std::time::Duration::from_secs(30),
334 metrics: MetricsConfig { enabled: true },
335 owned_shards: Vec::new(),
336 cors_allowed_origins: Vec::new(),
337 }
338 }
339
340 async fn deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
341 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
342 let engine = Arc::new(
343 EngineBuilder::new()
344 .store_arc(store)
345 .in_memory_visibility()
346 .scheduler_threads(1)
347 .build()
348 .await?,
349 );
350 let resolver = NamespaceResolver::from_parts(
351 NamespaceMode::SharedEngine,
352 Some(engine),
353 Arc::new(StaticWorkflowNamespaces::default()),
354 Arc::new(StaticScheduleNamespaces::default()),
355 );
356 let mut config = runtime_config();
357 config.deploy = DeployConfig {
358 enabled: true,
359 max_archive_bytes: Some(1024),
360 max_inflated_bytes: Some(2048),
361 };
362 Ok(ServerState::from_parts(resolver, config))
363 }
364
365 #[cfg(not(feature = "auth"))]
368 const AUTH_TOKEN: &str = "deploy-secret";
369
370 #[cfg(not(feature = "auth"))]
373 async fn auth_on_deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
374 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
375 let engine = Arc::new(
376 EngineBuilder::new()
377 .store_arc(store)
378 .in_memory_visibility()
379 .scheduler_threads(1)
380 .build()
381 .await?,
382 );
383 let resolver = NamespaceResolver::from_parts(
384 NamespaceMode::SharedEngine,
385 Some(engine),
386 Arc::new(StaticWorkflowNamespaces::default()),
387 Arc::new(StaticScheduleNamespaces::default()),
388 );
389 let mut config = runtime_config();
390 config.auth = AuthConfig {
391 enabled: true,
392 jwks_url: Some(AUTH_TOKEN.to_owned()),
393 jwks_refresh_seconds: 300,
394 };
395 config.deploy = DeployConfig {
396 enabled: true,
397 max_archive_bytes: Some(1024),
398 max_inflated_bytes: Some(2048),
399 };
400 Ok(ServerState::from_parts(resolver, config))
401 }
402
403 fn granted_request<T>(message: T) -> Result<Request<T>, Box<dyn std::error::Error>> {
404 let mut request = Request::new(message);
405 request
406 .metadata_mut()
407 .insert("x-aion-subject", "ci".parse()?);
408 request
409 .metadata_mut()
410 .insert("x-aion-deploy", "true".parse()?);
411 Ok(request)
412 }
413
414 #[tokio::test]
418 async fn auth_off_operator_is_deploy_granted_without_metadata()
419 -> Result<(), Box<dyn std::error::Error>> {
420 use generated::deploy_service_server::DeployService as _;
421
422 let service = DeployGrpcService::new(deploy_state().await?);
423 let mut request = Request::new(generated::ListVersionsRequest {});
424 request
425 .metadata_mut()
426 .insert("x-aion-subject", "ci".parse()?);
427
428 let response = service.list_versions(request).await?;
429 assert!(response.into_inner().versions.is_empty());
430 Ok(())
431 }
432
433 #[cfg(not(feature = "auth"))]
437 #[tokio::test]
438 async fn auth_on_denies_caller_without_deploy_grant() -> Result<(), Box<dyn std::error::Error>>
439 {
440 use generated::deploy_service_server::DeployService as _;
441
442 let service = DeployGrpcService::new(auth_on_deploy_state().await?);
443 let mut request = Request::new(generated::ListVersionsRequest {});
444 request
446 .metadata_mut()
447 .insert("authorization", format!("Bearer {AUTH_TOKEN}").parse()?);
448 request
449 .metadata_mut()
450 .insert("x-aion-subject", "ci".parse()?);
451
452 let status = service
453 .list_versions(request)
454 .await
455 .err()
456 .ok_or("expected denial")?;
457 assert_eq!(status.code(), Code::PermissionDenied);
458 let detail = decode_detail(&status)?;
459 assert_eq!(detail.code, WireErrorCode::DeployDenied);
460 assert!(
461 detail.message.contains("x-aion-deploy"),
462 "denial must hint the dev header: {}",
463 detail.message
464 );
465 Ok(())
466 }
467
468 #[tokio::test]
469 async fn granted_metadata_lists_versions() -> Result<(), Box<dyn std::error::Error>> {
470 use generated::deploy_service_server::DeployService as _;
471
472 let service = DeployGrpcService::new(deploy_state().await?);
473 let response = service
474 .list_versions(granted_request(generated::ListVersionsRequest {})?)
475 .await?;
476 assert!(response.into_inner().versions.is_empty());
477 Ok(())
478 }
479
480 #[tokio::test]
481 async fn oversized_archive_is_invalid_argument_naming_the_key()
482 -> Result<(), Box<dyn std::error::Error>> {
483 use generated::deploy_service_server::DeployService as _;
484
485 let service = DeployGrpcService::new(deploy_state().await?);
486 let status = service
487 .load_package(granted_request(generated::LoadPackageRequest {
488 archive: vec![0_u8; 2048],
489 })?)
490 .await
491 .err()
492 .ok_or("expected oversize refusal")?;
493
494 assert_eq!(status.code(), Code::InvalidArgument);
495 assert!(
496 status.message().contains("deploy.max_archive_bytes"),
497 "refusal must name the config key: {}",
498 status.message()
499 );
500 Ok(())
501 }
502
503 #[tokio::test]
504 async fn route_to_unknown_version_is_not_found() -> Result<(), Box<dyn std::error::Error>> {
505 use generated::deploy_service_server::DeployService as _;
506
507 let service = DeployGrpcService::new(deploy_state().await?);
508 let status = service
509 .route_version(granted_request(generated::RouteVersionRequest {
510 workflow_type: "order".to_owned(),
511 content_hash: "a".repeat(64),
512 })?)
513 .await
514 .err()
515 .ok_or("expected unknown-version refusal")?;
516
517 assert_eq!(status.code(), Code::NotFound);
518 let detail = decode_detail(&status)?;
519 assert_eq!(detail.code, WireErrorCode::NotFound);
520 assert_eq!(detail.error_type.as_deref(), Some("UnknownVersion"));
521 Ok(())
522 }
523
524 #[tokio::test]
525 async fn malformed_hash_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
526 use generated::deploy_service_server::DeployService as _;
527
528 let service = DeployGrpcService::new(deploy_state().await?);
529 let status = service
530 .unload_version(granted_request(generated::UnloadVersionRequest {
531 workflow_type: "order".to_owned(),
532 content_hash: "not-a-hash".to_owned(),
533 })?)
534 .await
535 .err()
536 .ok_or("expected malformed-hash refusal")?;
537
538 assert_eq!(status.code(), Code::InvalidArgument);
539 assert!(
540 status.message().contains("not-a-hash"),
541 "refusal must name the malformed hash: {}",
542 status.message()
543 );
544 Ok(())
545 }
546
547 #[tokio::test]
550 async fn drain_refuses_mutations_but_serves_listing() -> Result<(), Box<dyn std::error::Error>>
551 {
552 use generated::deploy_service_server::DeployService as _;
553
554 let state = deploy_state().await?;
555 assert!(state.drain_state().begin());
556 let service = DeployGrpcService::new(state);
557
558 let status = service
559 .route_version(granted_request(generated::RouteVersionRequest {
560 workflow_type: "order".to_owned(),
561 content_hash: "a".repeat(64),
562 })?)
563 .await
564 .err()
565 .ok_or("expected drain refusal")?;
566 assert_eq!(status.code(), Code::Unavailable);
567 assert!(
568 status.message().contains("draining"),
569 "drain refusal must be explicit: {}",
570 status.message()
571 );
572
573 let listing = service
574 .list_versions(granted_request(generated::ListVersionsRequest {})?)
575 .await?;
576 assert!(listing.into_inner().versions.is_empty());
577 Ok(())
578 }
579}