cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

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 }