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 }))
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
161fn 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, DeployConfig, ListenConfig, MetricsConfig, NamespaceConfig,
185 NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig, WebSocketConfig,
186 WorkerConfig,
187 };
188 use crate::{
189 NamespaceResolver, ServerState, StaticScheduleNamespaces, StaticWorkflowNamespaces,
190 };
191
192 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 ops_console: OpsConsoleConfig {
211 source: OpsConsoleAssetSource::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 cluster_broadcast_capacity: Some(64),
223 },
224 workflow_packages: Vec::new(),
225 deploy: DeployConfig::default(),
226 authoring: AuthoringConfig::default(),
227 dev: crate::config::DevConfig::default(),
228 outbox: crate::config::OutboxConfig::default(),
229 observability: crate::config::ObservabilityConfig::default(),
230 scheduler_threads: 1,
231 query_timeout: Some(std::time::Duration::from_millis(10_000)),
232 default_namespace: "default".to_owned(),
233 auto_create: crate::config::AutoCreate::Open,
234 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
235 drain_timeout: std::time::Duration::from_secs(30),
236 metrics: MetricsConfig { enabled: true },
237 owned_shards: Vec::new(),
238 cors_allowed_origins: Vec::new(),
239 }
240 }
241
242 async fn deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
243 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
244 let engine = Arc::new(
245 EngineBuilder::new()
246 .store_arc(store)
247 .in_memory_visibility()
248 .scheduler_threads(1)
249 .build()
250 .await?,
251 );
252 let resolver = NamespaceResolver::from_parts(
253 NamespaceMode::SharedEngine,
254 Some(engine),
255 Arc::new(StaticWorkflowNamespaces::default()),
256 Arc::new(StaticScheduleNamespaces::default()),
257 );
258 let mut config = runtime_config();
259 config.deploy = DeployConfig {
260 enabled: true,
261 max_archive_bytes: Some(1024),
262 max_inflated_bytes: Some(2048),
263 };
264 Ok(ServerState::from_parts(resolver, config))
265 }
266
267 #[cfg(not(feature = "auth"))]
270 const AUTH_TOKEN: &str = "deploy-secret";
271
272 #[cfg(not(feature = "auth"))]
275 async fn auth_on_deploy_state() -> Result<ServerState, Box<dyn std::error::Error>> {
276 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
277 let engine = Arc::new(
278 EngineBuilder::new()
279 .store_arc(store)
280 .in_memory_visibility()
281 .scheduler_threads(1)
282 .build()
283 .await?,
284 );
285 let resolver = NamespaceResolver::from_parts(
286 NamespaceMode::SharedEngine,
287 Some(engine),
288 Arc::new(StaticWorkflowNamespaces::default()),
289 Arc::new(StaticScheduleNamespaces::default()),
290 );
291 let mut config = runtime_config();
292 config.auth = AuthConfig {
293 enabled: true,
294 jwks_url: Some(AUTH_TOKEN.to_owned()),
295 jwks_refresh_seconds: 300,
296 };
297 config.deploy = DeployConfig {
298 enabled: true,
299 max_archive_bytes: Some(1024),
300 max_inflated_bytes: Some(2048),
301 };
302 Ok(ServerState::from_parts(resolver, config))
303 }
304
305 fn granted_request<T>(message: T) -> Result<Request<T>, Box<dyn std::error::Error>> {
306 let mut request = Request::new(message);
307 request
308 .metadata_mut()
309 .insert("x-aion-subject", "ci".parse()?);
310 request
311 .metadata_mut()
312 .insert("x-aion-deploy", "true".parse()?);
313 Ok(request)
314 }
315
316 #[tokio::test]
320 async fn auth_off_operator_is_deploy_granted_without_metadata()
321 -> Result<(), Box<dyn std::error::Error>> {
322 use generated::deploy_service_server::DeployService as _;
323
324 let service = DeployGrpcService::new(deploy_state().await?);
325 let mut request = Request::new(generated::ListVersionsRequest {});
326 request
327 .metadata_mut()
328 .insert("x-aion-subject", "ci".parse()?);
329
330 let response = service.list_versions(request).await?;
331 assert!(response.into_inner().versions.is_empty());
332 Ok(())
333 }
334
335 #[cfg(not(feature = "auth"))]
339 #[tokio::test]
340 async fn auth_on_denies_caller_without_deploy_grant() -> Result<(), Box<dyn std::error::Error>>
341 {
342 use generated::deploy_service_server::DeployService as _;
343
344 let service = DeployGrpcService::new(auth_on_deploy_state().await?);
345 let mut request = Request::new(generated::ListVersionsRequest {});
346 request
348 .metadata_mut()
349 .insert("authorization", format!("Bearer {AUTH_TOKEN}").parse()?);
350 request
351 .metadata_mut()
352 .insert("x-aion-subject", "ci".parse()?);
353
354 let status = service
355 .list_versions(request)
356 .await
357 .err()
358 .ok_or("expected denial")?;
359 assert_eq!(status.code(), Code::PermissionDenied);
360 let detail = decode_detail(&status)?;
361 assert_eq!(detail.code, WireErrorCode::DeployDenied);
362 assert!(
363 detail.message.contains("x-aion-deploy"),
364 "denial must hint the dev header: {}",
365 detail.message
366 );
367 Ok(())
368 }
369
370 #[tokio::test]
371 async fn granted_metadata_lists_versions() -> Result<(), Box<dyn std::error::Error>> {
372 use generated::deploy_service_server::DeployService as _;
373
374 let service = DeployGrpcService::new(deploy_state().await?);
375 let response = service
376 .list_versions(granted_request(generated::ListVersionsRequest {})?)
377 .await?;
378 assert!(response.into_inner().versions.is_empty());
379 Ok(())
380 }
381
382 #[tokio::test]
383 async fn oversized_archive_is_invalid_argument_naming_the_key()
384 -> Result<(), Box<dyn std::error::Error>> {
385 use generated::deploy_service_server::DeployService as _;
386
387 let service = DeployGrpcService::new(deploy_state().await?);
388 let status = service
389 .load_package(granted_request(generated::LoadPackageRequest {
390 archive: vec![0_u8; 2048],
391 })?)
392 .await
393 .err()
394 .ok_or("expected oversize refusal")?;
395
396 assert_eq!(status.code(), Code::InvalidArgument);
397 assert!(
398 status.message().contains("deploy.max_archive_bytes"),
399 "refusal must name the config key: {}",
400 status.message()
401 );
402 Ok(())
403 }
404
405 #[tokio::test]
406 async fn route_to_unknown_version_is_not_found() -> Result<(), Box<dyn std::error::Error>> {
407 use generated::deploy_service_server::DeployService as _;
408
409 let service = DeployGrpcService::new(deploy_state().await?);
410 let status = service
411 .route_version(granted_request(generated::RouteVersionRequest {
412 workflow_type: "order".to_owned(),
413 content_hash: "a".repeat(64),
414 })?)
415 .await
416 .err()
417 .ok_or("expected unknown-version refusal")?;
418
419 assert_eq!(status.code(), Code::NotFound);
420 let detail = decode_detail(&status)?;
421 assert_eq!(detail.code, WireErrorCode::NotFound);
422 assert_eq!(detail.error_type.as_deref(), Some("UnknownVersion"));
423 Ok(())
424 }
425
426 #[tokio::test]
427 async fn malformed_hash_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
428 use generated::deploy_service_server::DeployService as _;
429
430 let service = DeployGrpcService::new(deploy_state().await?);
431 let status = service
432 .unload_version(granted_request(generated::UnloadVersionRequest {
433 workflow_type: "order".to_owned(),
434 content_hash: "not-a-hash".to_owned(),
435 })?)
436 .await
437 .err()
438 .ok_or("expected malformed-hash refusal")?;
439
440 assert_eq!(status.code(), Code::InvalidArgument);
441 assert!(
442 status.message().contains("not-a-hash"),
443 "refusal must name the malformed hash: {}",
444 status.message()
445 );
446 Ok(())
447 }
448
449 #[tokio::test]
452 async fn drain_refuses_mutations_but_serves_listing() -> Result<(), Box<dyn std::error::Error>>
453 {
454 use generated::deploy_service_server::DeployService as _;
455
456 let state = deploy_state().await?;
457 assert!(state.drain_state().begin());
458 let service = DeployGrpcService::new(state);
459
460 let status = service
461 .route_version(granted_request(generated::RouteVersionRequest {
462 workflow_type: "order".to_owned(),
463 content_hash: "a".repeat(64),
464 })?)
465 .await
466 .err()
467 .ok_or("expected drain refusal")?;
468 assert_eq!(status.code(), Code::Unavailable);
469 assert!(
470 status.message().contains("draining"),
471 "drain refusal must be explicit: {}",
472 status.message()
473 );
474
475 let listing = service
476 .list_versions(granted_request(generated::ListVersionsRequest {})?)
477 .await?;
478 assert!(listing.into_inner().versions.is_empty());
479 Ok(())
480 }
481}