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::config::DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED;
16use crate::{CallerIdentity, ServerState};
17
18const TRANSPORT: &str = "grpc";
19
20const LOAD_PACKAGE_FRAMING_ALLOWANCE: usize = 64;
29
30#[derive(Clone)]
32pub struct DeployGrpcService {
33 state: ServerState,
34}
35
36impl DeployGrpcService {
37 #[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
48pub 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 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
162fn 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 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 #[cfg(not(feature = "auth"))]
272 const AUTH_TOKEN: &str = "deploy-secret";
273
274 #[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 #[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 #[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 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 #[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}