farm.rs (23022B)
1 use serde::Serialize; 2 use serde_json::Value; 3 4 use crate::cli::global::{ 5 FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs, 6 FarmUpdateArgs, 7 }; 8 use crate::ops::{ 9 FarmCreateRequest, FarmCreateResult, FarmFulfillmentUpdateRequest, FarmFulfillmentUpdateResult, 10 FarmGetRequest, FarmGetResult, FarmLocationUpdateRequest, FarmLocationUpdateResult, 11 FarmProfileUpdateRequest, FarmProfileUpdateResult, FarmPublishRequest, FarmPublishResult, 12 FarmReadinessCheckRequest, FarmReadinessCheckResult, FarmRebindRequest, FarmRebindResult, 13 OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, 14 OperationResult, OperationResultData, OperationService, 15 }; 16 use crate::runtime::RuntimeError; 17 use crate::runtime::config::{PublishTransport, RuntimeConfig}; 18 use crate::view::runtime::{CommandDisposition, FarmPublishView}; 19 20 pub struct FarmOperationService<'a> { 21 config: &'a RuntimeConfig, 22 } 23 24 impl<'a> FarmOperationService<'a> { 25 pub fn new(config: &'a RuntimeConfig) -> Self { 26 Self { config } 27 } 28 } 29 30 impl OperationService<FarmCreateRequest> for FarmOperationService<'_> { 31 type Result = FarmCreateResult; 32 33 fn execute( 34 &self, 35 request: OperationRequest<FarmCreateRequest>, 36 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 37 let args = FarmCreateArgs { 38 scope: scope_input(&request)?, 39 farm_d_tag: string_input(&request, "farm_d_tag"), 40 name: string_input(&request, "name"), 41 display_name: string_input(&request, "display_name"), 42 about: string_input(&request, "about"), 43 website: string_input(&request, "website"), 44 picture: string_input(&request, "picture"), 45 banner: string_input(&request, "banner"), 46 location: string_input(&request, "location"), 47 city: string_input(&request, "city"), 48 region: string_input(&request, "region"), 49 country: string_input(&request, "country"), 50 delivery_method: string_input(&request, "delivery_method"), 51 }; 52 if request.context.dry_run { 53 let view = 54 crate::runtime::farm::init_preflight(self.config, &args).map_err(|error| { 55 OperationAdapterError::runtime_failure(request.operation_id(), error) 56 })?; 57 return serialized_operation_result::<FarmCreateResult, _>(&view); 58 } 59 60 let view = crate::runtime::farm::init(self.config, &args).map_err(|error| { 61 OperationAdapterError::runtime_failure(request.operation_id(), error) 62 })?; 63 serialized_operation_result::<FarmCreateResult, _>(&view) 64 } 65 } 66 67 impl OperationService<FarmGetRequest> for FarmOperationService<'_> { 68 type Result = FarmGetResult; 69 70 fn execute( 71 &self, 72 request: OperationRequest<FarmGetRequest>, 73 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 74 let args = FarmScopedArgs { 75 scope: scope_input(&request)?, 76 }; 77 let view = map_runtime(crate::runtime::farm::get(self.config, &args))?; 78 serialized_operation_result::<FarmGetResult, _>(&view) 79 } 80 } 81 82 impl OperationService<FarmRebindRequest> for FarmOperationService<'_> { 83 type Result = FarmRebindResult; 84 85 fn execute( 86 &self, 87 request: OperationRequest<FarmRebindRequest>, 88 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 89 let args = FarmRebindArgs { 90 scope: scope_input(&request)?, 91 selector: required_string(&request, "selector")?, 92 }; 93 if request.context.dry_run { 94 let view = 95 crate::runtime::farm::rebind_preflight(self.config, &args).map_err(|error| { 96 OperationAdapterError::runtime_failure(request.operation_id(), error) 97 })?; 98 return serialized_operation_result::<FarmRebindResult, _>(&view); 99 } 100 if request.context.requires_approval_token() { 101 return Err(OperationAdapterError::approval_required( 102 request.operation_id(), 103 )); 104 } 105 106 let view = crate::runtime::farm::rebind(self.config, &args).map_err(|error| { 107 OperationAdapterError::runtime_failure(request.operation_id(), error) 108 })?; 109 serialized_operation_result::<FarmRebindResult, _>(&view) 110 } 111 } 112 113 impl OperationService<FarmProfileUpdateRequest> for FarmOperationService<'_> { 114 type Result = FarmProfileUpdateResult; 115 116 fn execute( 117 &self, 118 request: OperationRequest<FarmProfileUpdateRequest>, 119 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 120 farm_set::<FarmProfileUpdateResult>(&request, self.config, profile_field(&request)?) 121 } 122 } 123 124 impl OperationService<FarmLocationUpdateRequest> for FarmOperationService<'_> { 125 type Result = FarmLocationUpdateResult; 126 127 fn execute( 128 &self, 129 request: OperationRequest<FarmLocationUpdateRequest>, 130 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 131 farm_set::<FarmLocationUpdateResult>(&request, self.config, location_field(&request)?) 132 } 133 } 134 135 impl OperationService<FarmFulfillmentUpdateRequest> for FarmOperationService<'_> { 136 type Result = FarmFulfillmentUpdateResult; 137 138 fn execute( 139 &self, 140 request: OperationRequest<FarmFulfillmentUpdateRequest>, 141 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 142 farm_set::<FarmFulfillmentUpdateResult>(&request, self.config, FarmFieldArg::Delivery) 143 } 144 } 145 146 impl OperationService<FarmReadinessCheckRequest> for FarmOperationService<'_> { 147 type Result = FarmReadinessCheckResult; 148 149 fn execute( 150 &self, 151 request: OperationRequest<FarmReadinessCheckRequest>, 152 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 153 let args = FarmScopedArgs { 154 scope: scope_input(&request)?, 155 }; 156 let view = map_runtime(crate::runtime::farm::status(self.config, &args))?; 157 serialized_operation_result::<FarmReadinessCheckResult, _>(&view) 158 } 159 } 160 161 impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { 162 type Result = FarmPublishResult; 163 164 fn execute( 165 &self, 166 request: OperationRequest<FarmPublishRequest>, 167 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 168 let args = FarmPublishArgs { 169 scope: scope_input(&request)?, 170 idempotency_key: request 171 .context 172 .idempotency_key 173 .clone() 174 .or_else(|| string_input(&request, "idempotency_key")), 175 print_event: bool_input(&request, "print_event").unwrap_or(false), 176 }; 177 if request.context.requires_approval_token() { 178 return Err(OperationAdapterError::approval_required( 179 request.operation_id(), 180 )); 181 } 182 if matches!( 183 self.config.publish.transport, 184 PublishTransport::DirectNostrRelay 185 ) { 186 require_relay_target(&request, self.config)?; 187 } 188 189 let view = crate::runtime::farm::publish(self.config, &args).map_err(|error| { 190 OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) 191 })?; 192 farm_publish_result(request.operation_id(), &view) 193 } 194 } 195 196 fn farm_set<R>( 197 request: &OperationRequest<impl OperationRequestPayload + OperationRequestData>, 198 config: &RuntimeConfig, 199 field: FarmFieldArg, 200 ) -> Result<OperationResult<R>, OperationAdapterError> 201 where 202 R: OperationResultData, 203 { 204 let value = required_string(request, "value")?; 205 let args = FarmUpdateArgs { 206 scope: scope_input(request)?, 207 field, 208 value: vec![value.clone()], 209 }; 210 if request.context.dry_run { 211 let view = map_runtime(crate::runtime::farm::set_preflight(config, &args))?; 212 return serialized_operation_result::<R, _>(&view); 213 } 214 215 let view = map_runtime(crate::runtime::farm::set(config, &args))?; 216 serialized_operation_result::<R, _>(&view) 217 } 218 219 fn profile_field( 220 request: &OperationRequest<impl OperationRequestPayload + OperationRequestData>, 221 ) -> Result<FarmFieldArg, OperationAdapterError> { 222 match string_input(request, "field").as_deref() { 223 Some("name") | None => Ok(FarmFieldArg::Name), 224 Some("display_name") | Some("display-name") => Ok(FarmFieldArg::DisplayName), 225 Some("about") => Ok(FarmFieldArg::About), 226 Some("website") => Ok(FarmFieldArg::Website), 227 Some("picture") => Ok(FarmFieldArg::Picture), 228 Some("banner") => Ok(FarmFieldArg::Banner), 229 Some(other) => Err(invalid_input( 230 request.operation_id(), 231 format!("profile field `{other}` is not supported"), 232 )), 233 } 234 } 235 236 fn location_field( 237 request: &OperationRequest<impl OperationRequestPayload + OperationRequestData>, 238 ) -> Result<FarmFieldArg, OperationAdapterError> { 239 match string_input(request, "field").as_deref() { 240 Some("location") | None => Ok(FarmFieldArg::Location), 241 Some("city") => Ok(FarmFieldArg::City), 242 Some("region") => Ok(FarmFieldArg::Region), 243 Some("country") => Ok(FarmFieldArg::Country), 244 Some(other) => Err(invalid_input( 245 request.operation_id(), 246 format!("location field `{other}` is not supported"), 247 )), 248 } 249 } 250 251 fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> 252 where 253 R: OperationResultData, 254 T: Serialize, 255 { 256 OperationResult::new(R::from_serializable(value)?) 257 } 258 259 fn farm_publish_result( 260 operation_id: &str, 261 view: &FarmPublishView, 262 ) -> Result<OperationResult<FarmPublishResult>, OperationAdapterError> { 263 match view.disposition() { 264 CommandDisposition::Success => serialized_operation_result::<FarmPublishResult, _>(view), 265 CommandDisposition::ExternalUnavailable if farm_publish_relay_unavailable(view) => { 266 Err(OperationAdapterError::network_unavailable_with_detail( 267 operation_id, 268 view.reason.clone().unwrap_or_else(|| { 269 format!("farm publish finished with state `{}`", view.state) 270 }), 271 serde_json::to_value(view).unwrap_or(Value::Null), 272 )) 273 } 274 disposition => Err(OperationAdapterError::from_command_disposition( 275 operation_id, 276 disposition, 277 view.reason.clone().unwrap_or_else(|| match disposition { 278 CommandDisposition::Success => "farm publish succeeded".to_owned(), 279 CommandDisposition::NotFound => "farm publish target was not found".to_owned(), 280 CommandDisposition::ValidationFailed => "farm publish validation failed".to_owned(), 281 CommandDisposition::Unconfigured => "farm publish is unconfigured".to_owned(), 282 CommandDisposition::ExternalUnavailable => "farm publish is unavailable".to_owned(), 283 CommandDisposition::Unsupported => "farm publish is unsupported".to_owned(), 284 CommandDisposition::InternalError => "farm publish failed".to_owned(), 285 }), 286 )), 287 } 288 } 289 290 fn farm_publish_relay_unavailable(view: &FarmPublishView) -> bool { 291 view.state == "partial" 292 || !view.profile.failed_relays.is_empty() 293 || !view.farm.failed_relays.is_empty() 294 } 295 296 fn require_relay_target<P>( 297 request: &OperationRequest<P>, 298 config: &RuntimeConfig, 299 ) -> Result<(), OperationAdapterError> 300 where 301 P: OperationRequestPayload, 302 { 303 if !config.relay.urls.is_empty() { 304 return Ok(()); 305 } 306 307 Err(OperationAdapterError::NetworkUnavailable { 308 operation_id: request.operation_id().to_owned(), 309 message: format!( 310 "`{}` requires at least one configured relay for direct relay publication", 311 request.spec.cli_path 312 ), 313 }) 314 } 315 316 fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { 317 result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) 318 } 319 320 fn scope_input<P>( 321 request: &OperationRequest<P>, 322 ) -> Result<Option<FarmScopeArg>, OperationAdapterError> 323 where 324 P: OperationRequestPayload + OperationRequestData, 325 { 326 match string_input(request, "scope").as_deref() { 327 Some("user") => Ok(Some(FarmScopeArg::User)), 328 Some("workspace") => Ok(Some(FarmScopeArg::Workspace)), 329 Some(other) => Err(invalid_input( 330 request.operation_id(), 331 format!("scope must be `user` or `workspace`, got `{other}`"), 332 )), 333 None => Ok(None), 334 } 335 } 336 337 fn required_string<P>( 338 request: &OperationRequest<P>, 339 key: &str, 340 ) -> Result<String, OperationAdapterError> 341 where 342 P: OperationRequestPayload + OperationRequestData, 343 { 344 string_input(request, key).ok_or_else(|| { 345 invalid_input( 346 request.operation_id(), 347 format!("missing required `{key}` input"), 348 ) 349 }) 350 } 351 352 fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> 353 where 354 P: OperationRequestPayload + OperationRequestData, 355 { 356 request 357 .payload 358 .input() 359 .get(key) 360 .and_then(Value::as_str) 361 .map(str::to_owned) 362 } 363 364 fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> 365 where 366 P: OperationRequestPayload + OperationRequestData, 367 { 368 request.payload.input().get(key).and_then(Value::as_bool) 369 } 370 371 fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { 372 OperationAdapterError::InvalidInput { 373 operation_id: operation_id.to_owned(), 374 message, 375 } 376 } 377 378 #[cfg(test)] 379 mod tests { 380 use std::path::{Path, PathBuf}; 381 382 use radroots_runtime_paths::RadrootsMigrationReport; 383 use radroots_secret_vault::RadrootsSecretBackend; 384 use serde_json::{Map, Value}; 385 use tempfile::tempdir; 386 387 use super::FarmOperationService; 388 use crate::ops::{ 389 FarmCreateRequest, FarmGetRequest, FarmPublishRequest, FarmReadinessCheckRequest, 390 FarmRebindRequest, OperationAdapter, OperationContext, OperationData, OperationRequest, 391 }; 392 use crate::runtime::config::{ 393 AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, 394 LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, 395 PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, 396 RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, 397 SignerConfig, Verbosity, 398 }; 399 400 #[test] 401 fn farm_service_reports_missing_farm_config() { 402 let dir = tempdir().expect("tempdir"); 403 let config = sample_config(dir.path()); 404 let service = OperationAdapter::new(FarmOperationService::new(&config)); 405 let request = OperationRequest::new(OperationContext::default(), FarmGetRequest::default()) 406 .expect("farm get request"); 407 let envelope = service 408 .execute(request) 409 .expect("farm get result") 410 .to_envelope(OperationContext::default().envelope_context("req_farm_get")) 411 .expect("farm get envelope"); 412 413 assert_eq!(envelope.operation_id, "farm.get"); 414 assert_eq!(envelope.result["state"], "unconfigured"); 415 assert_eq!(envelope.result["config_present"], false); 416 } 417 418 #[test] 419 fn farm_service_supports_create_and_readiness_dry_run() { 420 let dir = tempdir().expect("tempdir"); 421 let config = sample_config(dir.path()); 422 let service = OperationAdapter::new(FarmOperationService::new(&config)); 423 let mut context = OperationContext::default(); 424 context.dry_run = true; 425 let request = OperationRequest::new( 426 context.clone(), 427 FarmCreateRequest::from_data(data(&[("name", "dry farm"), ("location", "earth")])), 428 ) 429 .expect("farm create request"); 430 let envelope = service 431 .execute(request) 432 .expect("farm create result") 433 .to_envelope(context.envelope_context("req_farm_create")) 434 .expect("farm create envelope"); 435 436 assert_eq!(envelope.operation_id, "farm.create"); 437 assert_eq!(envelope.dry_run, true); 438 assert_eq!(envelope.result["state"], "unconfigured"); 439 440 let readiness = OperationRequest::new( 441 OperationContext::default(), 442 FarmReadinessCheckRequest::default(), 443 ) 444 .expect("farm readiness request"); 445 let readiness_envelope = service 446 .execute(readiness) 447 .expect("farm readiness result") 448 .to_envelope(OperationContext::default().envelope_context("req_farm_ready")) 449 .expect("farm readiness envelope"); 450 assert_eq!(readiness_envelope.operation_id, "farm.readiness.check"); 451 assert_eq!(readiness_envelope.result["state"], "unconfigured"); 452 } 453 454 #[test] 455 fn farm_publish_requires_approval_token_unless_dry_run() { 456 let dir = tempdir().expect("tempdir"); 457 let config = sample_config(dir.path()); 458 let service = OperationAdapter::new(FarmOperationService::new(&config)); 459 let request = 460 OperationRequest::new(OperationContext::default(), FarmPublishRequest::default()) 461 .expect("farm publish request"); 462 let error = service.execute(request).expect_err("approval required"); 463 assert!(format!("{error}").contains("approval_token")); 464 assert_eq!(error.to_output_error().code, "approval_required"); 465 assert_eq!(error.to_output_error().exit_code, 6); 466 } 467 468 #[test] 469 fn farm_rebind_requires_approval_token_unless_dry_run() { 470 let dir = tempdir().expect("tempdir"); 471 let config = sample_config(dir.path()); 472 let service = OperationAdapter::new(FarmOperationService::new(&config)); 473 let request = OperationRequest::new( 474 OperationContext::default(), 475 FarmRebindRequest::from_data(data(&[("selector", "acct_test")])), 476 ) 477 .expect("farm rebind request"); 478 let error = service.execute(request).expect_err("approval required"); 479 assert!(format!("{error}").contains("approval_token")); 480 assert_eq!(error.to_output_error().code, "approval_required"); 481 assert_eq!(error.to_output_error().exit_code, 6); 482 } 483 484 fn sample_config(root: &Path) -> RuntimeConfig { 485 let data = root.join("data"); 486 let logs = root.join("logs"); 487 let secrets = root.join("secrets"); 488 RuntimeConfig { 489 output: OutputConfig { 490 format: OutputFormat::Human, 491 verbosity: Verbosity::Normal, 492 color: true, 493 dry_run: false, 494 }, 495 interaction: InteractionConfig { 496 input_enabled: true, 497 assume_yes: false, 498 stdin_tty: false, 499 stdout_tty: false, 500 prompts_allowed: false, 501 confirmations_allowed: false, 502 }, 503 paths: PathsConfig { 504 profile: "interactive_user".into(), 505 profile_source: "test".into(), 506 allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], 507 root_source: "test".into(), 508 repo_local_root: None, 509 repo_local_root_source: None, 510 subordinate_path_override_source: "runtime_config".into(), 511 app_namespace: "apps/cli".into(), 512 shared_accounts_namespace: "shared/accounts".into(), 513 shared_identities_namespace: "shared/identities".into(), 514 app_config_path: root.join("config/apps/cli/config.toml"), 515 workspace_config_path: None, 516 app_data_root: data.join("apps/cli"), 517 app_logs_root: logs.join("apps/cli"), 518 shared_accounts_data_root: data.join("shared/accounts"), 519 shared_accounts_secrets_root: secrets.join("shared/accounts"), 520 default_identity_path: secrets.join("shared/identities/default.json"), 521 }, 522 migration: MigrationConfig { 523 report: RadrootsMigrationReport::empty(), 524 }, 525 logging: LoggingConfig { 526 filter: "info".into(), 527 directory: None, 528 stdout: false, 529 }, 530 account: AccountConfig { 531 selector: None, 532 store_path: data.join("shared/accounts/store.json"), 533 secrets_dir: secrets.join("shared/accounts"), 534 secret_backend: RadrootsSecretBackend::EncryptedFile, 535 secret_fallback: None, 536 }, 537 account_secret_contract: AccountSecretContractConfig { 538 default_backend: "host_vault".into(), 539 default_fallback: Some("encrypted_file".into()), 540 allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], 541 host_vault_policy: Some("desktop".into()), 542 uses_protected_store: true, 543 }, 544 identity: IdentityConfig { 545 path: secrets.join("shared/identities/default.json"), 546 }, 547 signer: SignerConfig { 548 backend: SignerBackend::Local, 549 }, 550 publish: PublishConfig { 551 transport: PublishTransport::DirectNostrRelay, 552 source: PublishTransportSource::Defaults, 553 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 554 }, 555 relay: RelayConfig { 556 urls: Vec::new(), 557 publish_policy: RelayPublishPolicy::Any, 558 source: RelayConfigSource::Defaults, 559 }, 560 local: LocalConfig { 561 root: data.join("apps/cli/replica"), 562 replica_db_path: data.join("apps/cli/replica/replica.sqlite"), 563 backups_dir: data.join("apps/cli/replica/backups"), 564 exports_dir: data.join("apps/cli/replica/exports"), 565 }, 566 myc: MycConfig { 567 executable: PathBuf::from("myc"), 568 status_timeout_ms: 2_000, 569 }, 570 hyf: HyfConfig { 571 enabled: false, 572 executable: PathBuf::from("hyfd"), 573 }, 574 rpc: RpcConfig { 575 url: "http://127.0.0.1:7070".into(), 576 }, 577 rhi: crate::runtime::config::RhiConfig { 578 trusted_worker_pubkeys: Vec::new(), 579 }, 580 capability_bindings: Vec::new(), 581 } 582 } 583 584 fn data(entries: &[(&str, &str)]) -> OperationData { 585 entries 586 .iter() 587 .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) 588 .collect::<Map<String, Value>>() 589 } 590 }