From 53fd8d0c4f78db4050ea8cbc22bb0ef4332895e5 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sun, 28 Aug 2022 18:30:36 -0500 Subject: [PATCH] Add capabilities support to server and manager --- CHANGELOG.md | 2 + distant-core/src/api.rs | 19 +- distant-core/src/api/local.rs | 10 +- distant-core/src/data.rs | 54 ++++- distant-core/src/data/capabilities.rs | 198 +++++++++++++++++ distant-core/src/manager/client.rs | 17 +- distant-core/src/manager/data.rs | 3 + distant-core/src/manager/data/capabilities.rs | 203 ++++++++++++++++++ distant-core/src/manager/data/request.rs | 37 +++- distant-core/src/manager/data/response.rs | 5 +- distant-core/src/manager/server.rs | 13 +- distant-ssh2/src/api.rs | 9 +- src/cli/commands/client/format.rs | 16 ++ src/cli/commands/manager.rs | 35 +++ tests/cli/action/capabilities.rs | 63 ++++++ tests/cli/action/mod.rs | 1 + tests/cli/manager/capabilities.rs | 39 ++++ tests/cli/manager/mod.rs | 1 + tests/cli/mod.rs | 1 + tests/cli/repl/capabilities.rs | 33 +++ tests/cli/repl/mod.rs | 1 + 21 files changed, 749 insertions(+), 11 deletions(-) create mode 100644 distant-core/src/data/capabilities.rs create mode 100644 distant-core/src/manager/data/capabilities.rs create mode 100644 tests/cli/action/capabilities.rs create mode 100644 tests/cli/manager/capabilities.rs create mode 100644 tests/cli/manager/mod.rs create mode 100644 tests/cli/repl/capabilities.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a6a72..e5d4757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `SystemInfo` via ssh backend now detects and reports username and shell - `SystemInfo` via ssh backend now reports os when windows detected +- `Capabilities` request/response for server and manager that report back the + capabilities (and descriptions) supported by the server or manager ### Changed diff --git a/distant-core/src/api.rs b/distant-core/src/api.rs index a91fdc9..3553791 100644 --- a/distant-core/src/api.rs +++ b/distant-core/src/api.rs @@ -1,5 +1,8 @@ use crate::{ - data::{ChangeKind, DirEntry, Environment, Error, Metadata, ProcessId, PtySize, SystemInfo}, + data::{ + Capabilities, ChangeKind, DirEntry, Environment, Error, Metadata, ProcessId, PtySize, + SystemInfo, + }, ConnectionId, DistantMsg, DistantRequestData, DistantResponseData, }; use async_trait::async_trait; @@ -70,6 +73,14 @@ pub trait DistantApi { #[allow(unused_variables)] async fn on_accept(&self, local_data: &mut Self::LocalData) {} + /// Retrieves information about the server's capabilities. + /// + /// *Override this, otherwise it will return "unsupported" as an error.* + #[allow(unused_variables)] + async fn capabilities(&self, ctx: DistantCtx) -> io::Result { + unsupported("capabilities") + } + /// Reads bytes from a file. /// /// * `path` - the path to the file @@ -488,6 +499,12 @@ where D: Send + Sync, { match request { + DistantRequestData::Capabilities {} => server + .api + .capabilities(ctx) + .await + .map(|supported| DistantResponseData::Capabilities { supported }) + .unwrap_or_else(DistantResponseData::from), DistantRequestData::FileRead { path } => server .api .read_file(ctx, path) diff --git a/distant-core/src/api/local.rs b/distant-core/src/api/local.rs index 2cff342..974297c 100644 --- a/distant-core/src/api/local.rs +++ b/distant-core/src/api/local.rs @@ -1,7 +1,7 @@ use crate::{ data::{ - ChangeKind, ChangeKindSet, DirEntry, Environment, FileType, Metadata, ProcessId, PtySize, - SystemInfo, + Capabilities, ChangeKind, ChangeKindSet, DirEntry, Environment, FileType, Metadata, + ProcessId, PtySize, SystemInfo, }, DistantApi, DistantCtx, }; @@ -54,6 +54,12 @@ impl DistantApi for LocalDistantApi { local_data.watcher_channel = self.state.watcher.clone_channel(); } + async fn capabilities(&self, ctx: DistantCtx) -> io::Result { + debug!("[Conn {}] Querying capabilities", ctx.connection_id); + + Ok(Capabilities::all()) + } + async fn read_file( &self, ctx: DistantCtx, diff --git a/distant-core/src/data.rs b/distant-core/src/data.rs index debc7ec..1104f7f 100644 --- a/distant-core/src/data.rs +++ b/distant-core/src/data.rs @@ -1,11 +1,14 @@ use derive_more::{From, IsVariant}; use serde::{Deserialize, Serialize}; use std::{io, path::PathBuf}; -use strum::AsRefStr; +use strum::{AsRefStr, EnumDiscriminants, EnumIter, EnumMessage, EnumString}; #[cfg(feature = "clap")] use strum::VariantNames; +mod capabilities; +pub use capabilities::*; + mod change; pub use change::*; @@ -138,14 +141,37 @@ impl DistantMsg { } /// Represents the payload of a request to be performed on the remote machine -#[derive(Clone, Debug, PartialEq, Eq, IsVariant, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants, IsVariant, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "clap", derive(clap::Subcommand))] +#[strum_discriminants(derive( + strum::Display, + EnumIter, + EnumMessage, + EnumString, + Hash, + PartialOrd, + Ord, + IsVariant, + Serialize, + Deserialize +))] +#[cfg_attr( + feature = "schemars", + strum_discriminants(derive(schemars::JsonSchema)) +)] +#[strum_discriminants(name(CapabilityKind))] +#[strum_discriminants(strum(serialize_all = "snake_case"))] #[serde(rename_all = "snake_case", deny_unknown_fields, tag = "type")] #[cfg_attr(feature = "clap", clap(rename_all = "kebab-case"))] pub enum DistantRequestData { + /// Retrieve information about the server's capabilities + #[strum_discriminants(strum(message = "Supports retrieving capabilities"))] + Capabilities {}, + /// Reads a file from the specified path on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["cat"]))] + #[strum_discriminants(strum(message = "Supports reading binary file"))] FileRead { /// The path to the file on the remote machine path: PathBuf, @@ -153,6 +179,7 @@ pub enum DistantRequestData { /// Reads a file from the specified path on the remote machine /// and treats the contents as text + #[strum_discriminants(strum(message = "Supports reading text file"))] FileReadText { /// The path to the file on the remote machine path: PathBuf, @@ -160,6 +187,7 @@ pub enum DistantRequestData { /// Writes a file, creating it if it does not exist, and overwriting any existing content /// on the remote machine + #[strum_discriminants(strum(message = "Supports writing binary file"))] FileWrite { /// The path to the file on the remote machine path: PathBuf, @@ -171,6 +199,7 @@ pub enum DistantRequestData { /// Writes a file using text instead of bytes, creating it if it does not exist, /// and overwriting any existing content on the remote machine + #[strum_discriminants(strum(message = "Supports writing text file"))] FileWriteText { /// The path to the file on the remote machine path: PathBuf, @@ -180,6 +209,7 @@ pub enum DistantRequestData { }, /// Appends to a file, creating it if it does not exist, on the remote machine + #[strum_discriminants(strum(message = "Supports appending to binary file"))] FileAppend { /// The path to the file on the remote machine path: PathBuf, @@ -190,6 +220,7 @@ pub enum DistantRequestData { }, /// Appends text to a file, creating it if it does not exist, on the remote machine + #[strum_discriminants(strum(message = "Supports appending to text file"))] FileAppendText { /// The path to the file on the remote machine path: PathBuf, @@ -200,6 +231,7 @@ pub enum DistantRequestData { /// Reads a directory from the specified path on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["ls"]))] + #[strum_discriminants(strum(message = "Supports reading directory"))] DirRead { /// The path to the directory on the remote machine path: PathBuf, @@ -238,6 +270,7 @@ pub enum DistantRequestData { /// Creates a directory on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["mkdir"]))] + #[strum_discriminants(strum(message = "Supports creating directory"))] DirCreate { /// The path to the directory on the remote machine path: PathBuf, @@ -250,6 +283,7 @@ pub enum DistantRequestData { /// Removes a file or directory on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["rm"]))] + #[strum_discriminants(strum(message = "Supports removing files, directories, and symlinks"))] Remove { /// The path to the file or directory on the remote machine path: PathBuf, @@ -263,6 +297,7 @@ pub enum DistantRequestData { /// Copies a file or directory on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["cp"]))] + #[strum_discriminants(strum(message = "Supports copying files, directories, and symlinks"))] Copy { /// The path to the file or directory on the remote machine src: PathBuf, @@ -273,6 +308,7 @@ pub enum DistantRequestData { /// Moves/renames a file or directory on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["mv"]))] + #[strum_discriminants(strum(message = "Supports renaming files, directories, and symlinks"))] Rename { /// The path to the file or directory on the remote machine src: PathBuf, @@ -282,6 +318,7 @@ pub enum DistantRequestData { }, /// Watches a path for changes + #[strum_discriminants(strum(message = "Supports watching filesystem for changes"))] Watch { /// The path to the file, directory, or symlink on the remote machine path: PathBuf, @@ -310,18 +347,23 @@ pub enum DistantRequestData { }, /// Unwatches a path for changes, meaning no additional changes will be reported + #[strum_discriminants(strum(message = "Supports unwatching filesystem for changes"))] Unwatch { /// The path to the file, directory, or symlink on the remote machine path: PathBuf, }, /// Checks whether the given path exists + #[strum_discriminants(strum(message = "Supports checking if a path exists"))] Exists { /// The path to the file or directory on the remote machine path: PathBuf, }, /// Retrieves filesystem metadata for the specified path on the remote machine + #[strum_discriminants(strum( + message = "Supports retrieving metadata about a file, directory, or symlink" + ))] Metadata { /// The path to the file, directory, or symlink on the remote machine path: PathBuf, @@ -341,6 +383,7 @@ pub enum DistantRequestData { /// Spawns a new process on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["spawn", "run"]))] + #[strum_discriminants(strum(message = "Supports spawning a process"))] ProcSpawn { /// The full command to run including arguments #[cfg_attr(feature = "clap", clap(flatten))] @@ -370,12 +413,14 @@ pub enum DistantRequestData { /// Kills a process running on the remote machine #[cfg_attr(feature = "clap", clap(visible_aliases = &["kill"]))] + #[strum_discriminants(strum(message = "Supports killing a spawned process"))] ProcKill { /// Id of the actively-running process id: ProcessId, }, /// Sends additional data to stdin of running process + #[strum_discriminants(strum(message = "Supports sending stdin to a spawned process"))] ProcStdin { /// Id of the actively-running process to send stdin data id: ProcessId, @@ -387,6 +432,7 @@ pub enum DistantRequestData { }, /// Resize pty of remote process + #[strum_discriminants(strum(message = "Supports resizing the pty of a spawned process"))] ProcResizePty { /// Id of the actively-running process whose pty to resize id: ProcessId, @@ -396,6 +442,7 @@ pub enum DistantRequestData { }, /// Retrieve information about the server and the system it is on + #[strum_discriminants(strum(message = "Supports retrieving system information"))] SystemInfo {}, } @@ -494,6 +541,9 @@ pub enum DistantResponseData { /// Response to retrieving information about the server and the system it is on SystemInfo(SystemInfo), + + /// Response to retrieving information about the server's capabilities + Capabilities { supported: Capabilities }, } #[cfg(feature = "schemars")] diff --git a/distant-core/src/data/capabilities.rs b/distant-core/src/data/capabilities.rs new file mode 100644 index 0000000..4a8fff6 --- /dev/null +++ b/distant-core/src/data/capabilities.rs @@ -0,0 +1,198 @@ +use super::CapabilityKind; +use derive_more::{From, Into, IntoIterator}; +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + collections::HashSet, + hash::{Hash, Hasher}, + ops::{BitAnd, BitOr, BitXor}, + str::FromStr, +}; +use strum::{EnumMessage, IntoEnumIterator}; + +/// Set of supported capabilities for a server +#[derive(Clone, Debug, From, Into, PartialEq, Eq, IntoIterator, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(transparent)] +pub struct Capabilities(#[into_iterator(owned, ref)] HashSet); + +impl Capabilities { + /// Return set of capabilities encompassing all possible capabilities + pub fn all() -> Self { + Self(CapabilityKind::iter().map(Capability::from).collect()) + } + + /// Return empty set of capabilities + pub fn none() -> Self { + Self(HashSet::new()) + } + + /// Returns true if the capability with described kind is included + pub fn contains(&self, kind: impl AsRef) -> bool { + let cap = Capability { + kind: kind.as_ref().to_string(), + description: String::new(), + }; + self.0.contains(&cap) + } + + /// Adds the specified capability to the set of capabilities + /// + /// * If the set did not have this capability, returns `true` + /// * If the set did have this capability, returns `false` + pub fn insert(&mut self, cap: impl Into) -> bool { + self.0.insert(cap.into()) + } + + /// Removes the capability with the described kind, returning the capability + pub fn take(&mut self, kind: impl AsRef) -> Option { + let cap = Capability { + kind: kind.as_ref().to_string(), + description: String::new(), + }; + self.0.take(&cap) + } + + /// Converts into vec of capabilities sorted by kind + pub fn into_sorted_vec(self) -> Vec { + let mut this = self.0.into_iter().collect::>(); + + this.sort_unstable(); + + this + } +} + +#[cfg(feature = "schemars")] +impl Capabilities { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(Capabilities) + } +} + +impl BitAnd for &Capabilities { + type Output = Capabilities; + + fn bitand(self, rhs: Self) -> Self::Output { + Capabilities(self.0.bitand(&rhs.0)) + } +} + +impl BitOr for &Capabilities { + type Output = Capabilities; + + fn bitor(self, rhs: Self) -> Self::Output { + Capabilities(self.0.bitor(&rhs.0)) + } +} + +impl BitOr for &Capabilities { + type Output = Capabilities; + + fn bitor(self, rhs: Capability) -> Self::Output { + let mut other = Capabilities::none(); + other.0.insert(rhs); + + self.bitor(&other) + } +} + +impl BitXor for &Capabilities { + type Output = Capabilities; + + fn bitxor(self, rhs: Self) -> Self::Output { + Capabilities(self.0.bitxor(&rhs.0)) + } +} + +impl FromIterator for Capabilities { + fn from_iter>(iter: I) -> Self { + let mut this = Capabilities::none(); + + for capability in iter { + this.0.insert(capability); + } + + this + } +} + +/// Capability tied to a server. A capability is equivalent based on its kind and not description. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct Capability { + /// Label describing the kind of capability + pub kind: String, + + /// Information about the capability + pub description: String, +} + +impl Capability { + /// Will convert the [`Capability`]'s `kind` into a known [`CapabilityKind`] if possible, + /// returning None if the capability is unknown + pub fn to_capability_kind(&self) -> Option { + CapabilityKind::from_str(&self.kind).ok() + } + + /// Returns true if the described capability is unknown + pub fn is_unknown(&self) -> bool { + self.to_capability_kind().is_none() + } +} + +impl PartialEq for Capability { + fn eq(&self, other: &Self) -> bool { + self.kind.eq_ignore_ascii_case(&other.kind) + } +} + +impl Eq for Capability {} + +impl PartialOrd for Capability { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Capability { + fn cmp(&self, other: &Self) -> Ordering { + self.kind + .to_ascii_lowercase() + .cmp(&other.kind.to_ascii_lowercase()) + } +} + +impl Hash for Capability { + fn hash(&self, state: &mut H) { + self.kind.to_ascii_lowercase().hash(state); + } +} + +impl From for Capability { + /// Creates a new capability using the kind's default message + fn from(kind: CapabilityKind) -> Self { + Self { + kind: kind.to_string(), + description: kind + .get_message() + .map(ToString::to_string) + .unwrap_or_default(), + } + } +} + +#[cfg(feature = "schemars")] +impl Capability { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(Capability) + } +} + +#[cfg(feature = "schemars")] +impl CapabilityKind { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(CapabilityKind) + } +} diff --git a/distant-core/src/manager/client.rs b/distant-core/src/manager/client.rs index c260b2e..e9c1fa8 100644 --- a/distant-core/src/manager/client.rs +++ b/distant-core/src/manager/client.rs @@ -1,5 +1,6 @@ use super::data::{ - ConnectionId, ConnectionInfo, ConnectionList, Destination, ManagerRequest, ManagerResponse, + ConnectionId, ConnectionInfo, ConnectionList, Destination, ManagerCapabilities, ManagerRequest, + ManagerResponse, }; use crate::{ DistantChannel, DistantClient, DistantMsg, DistantRequestData, DistantResponseData, Map, @@ -301,6 +302,20 @@ impl DistantManagerClient { }) } + /// Retrieves a list of supported capabilities + pub async fn capabilities(&mut self) -> io::Result { + trace!("capabilities()"); + let res = self.client.send(ManagerRequest::Capabilities).await?; + match res.payload { + ManagerResponse::Capabilities { supported } => Ok(supported), + ManagerResponse::Error(x) => Err(x.into()), + x => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Got unexpected response: {:?}", x), + )), + } + } + /// Retrieves information about a specific connection pub async fn info(&mut self, id: ConnectionId) -> io::Result { trace!("info({})", id); diff --git a/distant-core/src/manager/data.rs b/distant-core/src/manager/data.rs index b23d742..11f6022 100644 --- a/distant-core/src/manager/data.rs +++ b/distant-core/src/manager/data.rs @@ -1,3 +1,6 @@ +mod capabilities; +pub use capabilities::*; + mod destination; pub use destination::*; diff --git a/distant-core/src/manager/data/capabilities.rs b/distant-core/src/manager/data/capabilities.rs new file mode 100644 index 0000000..0efd781 --- /dev/null +++ b/distant-core/src/manager/data/capabilities.rs @@ -0,0 +1,203 @@ +use super::ManagerCapabilityKind; +use derive_more::{From, Into, IntoIterator}; +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + collections::HashSet, + hash::{Hash, Hasher}, + ops::{BitAnd, BitOr, BitXor}, + str::FromStr, +}; +use strum::{EnumMessage, IntoEnumIterator}; + +/// Set of supported capabilities for a manager +#[derive(Clone, Debug, From, Into, PartialEq, Eq, IntoIterator, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(transparent)] +pub struct ManagerCapabilities(#[into_iterator(owned, ref)] HashSet); + +impl ManagerCapabilities { + /// Return set of capabilities encompassing all possible capabilities + pub fn all() -> Self { + Self( + ManagerCapabilityKind::iter() + .map(ManagerCapability::from) + .collect(), + ) + } + + /// Return empty set of capabilities + pub fn none() -> Self { + Self(HashSet::new()) + } + + /// Returns true if the capability with described kind is included + pub fn contains(&self, kind: impl AsRef) -> bool { + let cap = ManagerCapability { + kind: kind.as_ref().to_string(), + description: String::new(), + }; + self.0.contains(&cap) + } + + /// Adds the specified capability to the set of capabilities + /// + /// * If the set did not have this capability, returns `true` + /// * If the set did have this capability, returns `false` + pub fn insert(&mut self, cap: impl Into) -> bool { + self.0.insert(cap.into()) + } + + /// Removes the capability with the described kind, returning the capability + pub fn take(&mut self, kind: impl AsRef) -> Option { + let cap = ManagerCapability { + kind: kind.as_ref().to_string(), + description: String::new(), + }; + self.0.take(&cap) + } + + /// Converts into vec of capabilities sorted by kind + pub fn into_sorted_vec(self) -> Vec { + let mut this = self.0.into_iter().collect::>(); + + this.sort_unstable(); + + this + } +} + +#[cfg(feature = "schemars")] +impl ManagerCapabilities { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(ManagerCapabilities) + } +} + +impl BitAnd for &ManagerCapabilities { + type Output = ManagerCapabilities; + + fn bitand(self, rhs: Self) -> Self::Output { + ManagerCapabilities(self.0.bitand(&rhs.0)) + } +} + +impl BitOr for &ManagerCapabilities { + type Output = ManagerCapabilities; + + fn bitor(self, rhs: Self) -> Self::Output { + ManagerCapabilities(self.0.bitor(&rhs.0)) + } +} + +impl BitOr for &ManagerCapabilities { + type Output = ManagerCapabilities; + + fn bitor(self, rhs: ManagerCapability) -> Self::Output { + let mut other = ManagerCapabilities::none(); + other.0.insert(rhs); + + self.bitor(&other) + } +} + +impl BitXor for &ManagerCapabilities { + type Output = ManagerCapabilities; + + fn bitxor(self, rhs: Self) -> Self::Output { + ManagerCapabilities(self.0.bitxor(&rhs.0)) + } +} + +impl FromIterator for ManagerCapabilities { + fn from_iter>(iter: I) -> Self { + let mut this = ManagerCapabilities::none(); + + for capability in iter { + this.0.insert(capability); + } + + this + } +} + +/// ManagerCapability tied to a manager. A capability is equivalent based on its kind and not +/// description. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct ManagerCapability { + /// Label describing the kind of capability + pub kind: String, + + /// Information about the capability + pub description: String, +} + +impl ManagerCapability { + /// Will convert the [`ManagerCapability`]'s `kind` into a known [`ManagerCapabilityKind`] if + /// possible, returning None if the capability is unknown + pub fn to_capability_kind(&self) -> Option { + ManagerCapabilityKind::from_str(&self.kind).ok() + } + + /// Returns true if the described capability is unknown + pub fn is_unknown(&self) -> bool { + self.to_capability_kind().is_none() + } +} + +impl PartialEq for ManagerCapability { + fn eq(&self, other: &Self) -> bool { + self.kind.eq_ignore_ascii_case(&other.kind) + } +} + +impl Eq for ManagerCapability {} + +impl PartialOrd for ManagerCapability { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ManagerCapability { + fn cmp(&self, other: &Self) -> Ordering { + self.kind + .to_ascii_lowercase() + .cmp(&other.kind.to_ascii_lowercase()) + } +} + +impl Hash for ManagerCapability { + fn hash(&self, state: &mut H) { + self.kind.to_ascii_lowercase().hash(state); + } +} + +impl From for ManagerCapability { + /// Creates a new capability using the kind's default message + fn from(kind: ManagerCapabilityKind) -> Self { + Self { + kind: kind.to_string(), + description: kind + .get_message() + .map(ToString::to_string) + .unwrap_or_default(), + } + } +} + +#[cfg(feature = "schemars")] +impl ManagerCapability { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(ManagerCapability) + } +} + +#[cfg(feature = "schemars")] +impl ManagerCapabilityKind { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(ManagerCapabilityKind) + } +} diff --git a/distant-core/src/manager/data/request.rs b/distant-core/src/manager/data/request.rs index b049920..0d20f0a 100644 --- a/distant-core/src/manager/data/request.rs +++ b/distant-core/src/manager/data/request.rs @@ -1,13 +1,38 @@ use super::{ChannelId, ConnectionId, Destination}; use crate::{DistantMsg, DistantRequestData, Map}; +use derive_more::IsVariant; use distant_net::Request; use serde::{Deserialize, Serialize}; +use strum::{EnumDiscriminants, EnumIter, EnumMessage, EnumString}; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, EnumDiscriminants, Serialize, Deserialize)] #[cfg_attr(feature = "clap", derive(clap::Subcommand))] +#[strum_discriminants(derive( + strum::Display, + EnumIter, + EnumMessage, + EnumString, + Hash, + PartialOrd, + Ord, + IsVariant, + Serialize, + Deserialize +))] +#[cfg_attr( + feature = "schemars", + strum_discriminants(derive(schemars::JsonSchema)) +)] +#[strum_discriminants(name(ManagerCapabilityKind))] +#[strum_discriminants(strum(serialize_all = "snake_case"))] #[serde(rename_all = "snake_case", deny_unknown_fields, tag = "type")] pub enum ManagerRequest { + /// Retrieve information about the server's capabilities + #[strum_discriminants(strum(message = "Supports retrieving capabilities"))] + Capabilities, + /// Launch a server using the manager + #[strum_discriminants(strum(message = "Supports launching distant on remote servers"))] Launch { // NOTE: Boxed per clippy's large_enum_variant warning destination: Box, @@ -18,6 +43,7 @@ pub enum ManagerRequest { }, /// Initiate a connection through the manager + #[strum_discriminants(strum(message = "Supports connecting to remote servers"))] Connect { // NOTE: Boxed per clippy's large_enum_variant warning destination: Box, @@ -29,6 +55,7 @@ pub enum ManagerRequest { /// Opens a channel for communication with a server #[cfg_attr(feature = "clap", clap(skip))] + #[strum_discriminants(strum(message = "Supports opening a channel with a remote server"))] OpenChannel { /// Id of the connection id: ConnectionId, @@ -36,6 +63,9 @@ pub enum ManagerRequest { /// Sends data through channel #[cfg_attr(feature = "clap", clap(skip))] + #[strum_discriminants(strum( + message = "Supports sending data through a channel with a remote server" + ))] Channel { /// Id of the channel id: ChannelId, @@ -47,21 +77,26 @@ pub enum ManagerRequest { /// Closes an open channel #[cfg_attr(feature = "clap", clap(skip))] + #[strum_discriminants(strum(message = "Supports closing a channel with a remote server"))] CloseChannel { /// Id of the channel to close id: ChannelId, }, /// Retrieve information about a specific connection + #[strum_discriminants(strum(message = "Supports retrieving connection-specific information"))] Info { id: ConnectionId }, /// Kill a specific connection + #[strum_discriminants(strum(message = "Supports killing a remote connection"))] Kill { id: ConnectionId }, /// Retrieve list of connections being managed + #[strum_discriminants(strum(message = "Supports retrieving a list of managed connections"))] List, /// Signals the manager to shutdown + #[strum_discriminants(strum(message = "Supports being shut down on demand"))] Shutdown, } diff --git a/distant-core/src/manager/data/response.rs b/distant-core/src/manager/data/response.rs index 7a53260..e5b7687 100644 --- a/distant-core/src/manager/data/response.rs +++ b/distant-core/src/manager/data/response.rs @@ -1,4 +1,4 @@ -use crate::{data::Error, ConnectionInfo, ConnectionList, Destination}; +use crate::{data::Error, ConnectionInfo, ConnectionList, Destination, ManagerCapabilities}; use crate::{ChannelId, ConnectionId, DistantMsg, DistantResponseData}; use distant_net::Response; use serde::{Deserialize, Serialize}; @@ -15,6 +15,9 @@ pub enum ManagerResponse { /// Indicates that some error occurred during a request Error(Error), + /// Response to retrieving information about the manager's capabilities + Capabilities { supported: ManagerCapabilities }, + /// Confirmation of a distant server being launched Launched { /// Updated location of the spawned server diff --git a/distant-core/src/manager/server.rs b/distant-core/src/manager/server.rs index 3f15a4c..979a2da 100644 --- a/distant-core/src/manager/server.rs +++ b/distant-core/src/manager/server.rs @@ -1,6 +1,6 @@ use crate::{ - ChannelId, ConnectionId, ConnectionInfo, ConnectionList, Destination, ManagerRequest, - ManagerResponse, Map, + ChannelId, ConnectionId, ConnectionInfo, ConnectionList, Destination, ManagerCapabilities, + ManagerRequest, ManagerResponse, Map, }; use async_trait::async_trait; use distant_net::{ @@ -217,6 +217,11 @@ impl DistantManager { Ok(id) } + /// Retrieves the list of supported capabilities for this manager + async fn capabilities(&self) -> io::Result { + Ok(ManagerCapabilities::all()) + } + /// Retrieves information about the connection to the server with the specified `id` async fn info(&self, id: ConnectionId) -> io::Result { match self.connections.read().await.get(&id) { @@ -297,6 +302,10 @@ impl Server for DistantManager { } = ctx; let response = match request.payload { + ManagerRequest::Capabilities {} => match self.capabilities().await { + Ok(supported) => ManagerResponse::Capabilities { supported }, + Err(x) => ManagerResponse::Error(x.into()), + }, ManagerRequest::Launch { destination, options, diff --git a/distant-ssh2/src/api.rs b/distant-ssh2/src/api.rs index 2f6f9e4..a8ff2de 100644 --- a/distant-ssh2/src/api.rs +++ b/distant-ssh2/src/api.rs @@ -7,7 +7,8 @@ use async_once_cell::OnceCell; use async_trait::async_trait; use distant_core::{ data::{ - DirEntry, Environment, FileType, Metadata, ProcessId, PtySize, SystemInfo, UnixMetadata, + Capabilities, DirEntry, Environment, FileType, Metadata, ProcessId, PtySize, SystemInfo, + UnixMetadata, }, DistantApi, DistantCtx, }; @@ -78,6 +79,12 @@ impl DistantApi for SshDistantApi { local_data.global_processes = Arc::downgrade(&self.processes); } + async fn capabilities(&self, ctx: DistantCtx) -> io::Result { + debug!("[Conn {}] Querying capabilities", ctx.connection_id); + + Ok(Capabilities::all()) + } + async fn read_file( &self, ctx: DistantCtx, diff --git a/src/cli/commands/client/format.rs b/src/cli/commands/client/format.rs index 45a27bd..4a0e87a 100644 --- a/src/cli/commands/client/format.rs +++ b/src/cli/commands/client/format.rs @@ -318,5 +318,21 @@ fn format_shell(data: DistantResponseData) -> Output { ) .into_bytes(), ), + DistantResponseData::Capabilities { supported } => { + #[derive(Tabled)] + struct EntryRow { + kind: String, + description: String, + } + + let table = Table::new(supported.into_sorted_vec().into_iter().map(|cap| EntryRow { + kind: cap.kind, + description: cap.description, + })) + .with(Style::ascii()) + .with(Modify::new(Rows::new(..)).with(Alignment::left())); + + Output::Stdout(table.to_string().into_bytes()) + } } } diff --git a/src/cli/commands/manager.rs b/src/cli/commands/manager.rs index ea9306f..a55dfae 100644 --- a/src/cli/commands/manager.rs +++ b/src/cli/commands/manager.rs @@ -49,6 +49,12 @@ pub enum ManagerSubcommand { network: NetworkConfig, }, + /// Retrieve a list of capabilities that the manager supports + Capabilities { + #[clap(flatten)] + network: NetworkConfig, + }, + /// Retrieve information about a specific connection Info { id: ConnectionId, @@ -326,6 +332,35 @@ impl ManagerSubcommand { Ok(()) } + Self::Capabilities { network } => { + let network = network.merge(config.network); + debug!("Getting list of capabilities"); + let caps = Client::new(network) + .connect() + .await + .context("Failed to connect to manager")? + .capabilities() + .await + .context("Failed to get list of capabilities")?; + + #[derive(Tabled)] + struct CapabilityRow { + kind: String, + description: String, + } + + println!( + "{}", + Table::new(caps.into_sorted_vec().into_iter().map(|cap| { + CapabilityRow { + kind: cap.kind, + description: cap.description, + } + })) + ); + + Ok(()) + } Self::Info { network, id } => { let network = network.merge(config.network); debug!("Getting info about connection {}", id); diff --git a/tests/cli/action/capabilities.rs b/tests/cli/action/capabilities.rs new file mode 100644 index 0000000..6b6a150 --- /dev/null +++ b/tests/cli/action/capabilities.rs @@ -0,0 +1,63 @@ +use crate::cli::fixtures::*; +use assert_cmd::Command; +use indoc::indoc; +use rstest::*; + +const EXPECTED_TABLE: &str = indoc! {" ++------------------+------------------------------------------------------------------+ +| kind | description | ++------------------+------------------------------------------------------------------+ +| capabilities | Supports retrieving capabilities | ++------------------+------------------------------------------------------------------+ +| copy | Supports copying files, directories, and symlinks | ++------------------+------------------------------------------------------------------+ +| dir_create | Supports creating directory | ++------------------+------------------------------------------------------------------+ +| dir_read | Supports reading directory | ++------------------+------------------------------------------------------------------+ +| exists | Supports checking if a path exists | ++------------------+------------------------------------------------------------------+ +| file_append | Supports appending to binary file | ++------------------+------------------------------------------------------------------+ +| file_append_text | Supports appending to text file | ++------------------+------------------------------------------------------------------+ +| file_read | Supports reading binary file | ++------------------+------------------------------------------------------------------+ +| file_read_text | Supports reading text file | ++------------------+------------------------------------------------------------------+ +| file_write | Supports writing binary file | ++------------------+------------------------------------------------------------------+ +| file_write_text | Supports writing text file | ++------------------+------------------------------------------------------------------+ +| metadata | Supports retrieving metadata about a file, directory, or symlink | ++------------------+------------------------------------------------------------------+ +| proc_kill | Supports killing a spawned process | ++------------------+------------------------------------------------------------------+ +| proc_resize_pty | Supports resizing the pty of a spawned process | ++------------------+------------------------------------------------------------------+ +| proc_spawn | Supports spawning a process | ++------------------+------------------------------------------------------------------+ +| proc_stdin | Supports sending stdin to a spawned process | ++------------------+------------------------------------------------------------------+ +| remove | Supports removing files, directories, and symlinks | ++------------------+------------------------------------------------------------------+ +| rename | Supports renaming files, directories, and symlinks | ++------------------+------------------------------------------------------------------+ +| system_info | Supports retrieving system information | ++------------------+------------------------------------------------------------------+ +| unwatch | Supports unwatching filesystem for changes | ++------------------+------------------------------------------------------------------+ +| watch | Supports watching filesystem for changes | ++------------------+------------------------------------------------------------------+ +"}; + +#[rstest] +fn should_output_capabilities(mut action_cmd: CtxCommand) { + // distant action capabilities + action_cmd + .arg("capabilities") + .assert() + .success() + .stdout(EXPECTED_TABLE) + .stderr(""); +} diff --git a/tests/cli/action/mod.rs b/tests/cli/action/mod.rs index da3800f..39cfc6b 100644 --- a/tests/cli/action/mod.rs +++ b/tests/cli/action/mod.rs @@ -1,3 +1,4 @@ +mod capabilities; mod copy; mod dir_create; mod dir_read; diff --git a/tests/cli/manager/capabilities.rs b/tests/cli/manager/capabilities.rs new file mode 100644 index 0000000..0c31f98 --- /dev/null +++ b/tests/cli/manager/capabilities.rs @@ -0,0 +1,39 @@ +use crate::cli::fixtures::*; +use indoc::indoc; +use rstest::*; + +const EXPECTED_TABLE: &str = indoc! {" ++---------------+--------------------------------------------------------------+ +| kind | description | ++---------------+--------------------------------------------------------------+ +| capabilities | Supports retrieving capabilities | ++---------------+--------------------------------------------------------------+ +| channel | Supports sending data through a channel with a remote server | ++---------------+--------------------------------------------------------------+ +| close_channel | Supports closing a channel with a remote server | ++---------------+--------------------------------------------------------------+ +| connect | Supports connecting to remote servers | ++---------------+--------------------------------------------------------------+ +| info | Supports retrieving connection-specific information | ++---------------+--------------------------------------------------------------+ +| kill | Supports killing a remote connection | ++---------------+--------------------------------------------------------------+ +| launch | Supports launching distant on remote servers | ++---------------+--------------------------------------------------------------+ +| list | Supports retrieving a list of managed connections | ++---------------+--------------------------------------------------------------+ +| open_channel | Supports opening a channel with a remote server | ++---------------+--------------------------------------------------------------+ +| shutdown | Supports being shut down on demand | ++---------------+--------------------------------------------------------------+ +"}; + +#[rstest] +fn should_output_capabilities(ctx: DistantManagerCtx) { + // distant action capabilities + ctx.new_assert_cmd(vec!["manager", "capabilities"]) + .assert() + .success() + .stdout(format!("{EXPECTED_TABLE}\n")) + .stderr(""); +} diff --git a/tests/cli/manager/mod.rs b/tests/cli/manager/mod.rs new file mode 100644 index 0000000..98a3830 --- /dev/null +++ b/tests/cli/manager/mod.rs @@ -0,0 +1 @@ +mod capabilities; diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs index 5ec345a..f65369f 100644 --- a/tests/cli/mod.rs +++ b/tests/cli/mod.rs @@ -1,5 +1,6 @@ mod action; mod fixtures; +mod manager; mod repl; mod scripts; mod utils; diff --git a/tests/cli/repl/capabilities.rs b/tests/cli/repl/capabilities.rs new file mode 100644 index 0000000..c1db2d7 --- /dev/null +++ b/tests/cli/repl/capabilities.rs @@ -0,0 +1,33 @@ +use crate::cli::fixtures::*; +use distant_core::data::{Capabilities, Capability}; +use rstest::*; +use serde_json::json; + +#[rstest] +#[tokio::test] +async fn should_support_json_capabilities(mut json_repl: CtxCommand) { + let id = rand::random::().to_string(); + let req = json!({ + "id": id, + "payload": { "type": "capabilities" }, + }); + + let res = json_repl.write_and_read_json(req).await.unwrap().unwrap(); + + assert_eq!(res["origin_id"], id); + assert_eq!(res["payload"]["type"], "capabilities"); + + let supported: Capabilities = res["payload"]["supported"] + .as_array() + .expect("Field 'supported' was not an array") + .iter() + .map(|value| { + serde_json::from_value::(value.clone()) + .expect("Could not read array value as capability") + }) + .collect(); + + // NOTE: Our local server api should always support all capabilities since it is the reference + // implementation for our api + assert_eq!(supported, Capabilities::all()); +} diff --git a/tests/cli/repl/mod.rs b/tests/cli/repl/mod.rs index da3800f..39cfc6b 100644 --- a/tests/cli/repl/mod.rs +++ b/tests/cli/repl/mod.rs @@ -1,3 +1,4 @@ +mod capabilities; mod copy; mod dir_create; mod dir_read;