diff --git a/Cargo.lock b/Cargo.lock index 99d5b61..67f74b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d306632607af6ec61c0b117971d57a96381b6317cf18ae419b5558048fe016e" +dependencies = [ + "regex", +] + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2910,7 +2919,9 @@ dependencies = [ "openidconnect", "openssl", "predicates", + "rand 0.8.5", "regex", + "regex-macro", "reqwest 0.12.28", "self_update", "serde", diff --git a/Cargo.toml b/Cargo.toml index 040b420..00e9435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ console = { version = "0.15.11" } env_logger = { version = "0.11.5" } log = { version = "0.4.22" } openssl = { version = "0.10.75", optional = true } +rand = { version = "0.8" } reqwest = { version = "0.12.28", default-features = false, features = [ "json", "multipart", @@ -35,6 +36,7 @@ tabled = "0.20.0" chrono = "0.4.43" futures = { version = "0.3" } regex = { version = "1.12.3" } +regex-macro = { version = "0.3.0" } iso8601-timestamp = { version = "0.2.17" } toml = { version = "0.8.19" } git2 = { version = "0.19.0", default-features = false, features = [ diff --git a/src/cli.rs b/src/cli.rs index 66e1bd2..8974d40 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ pub mod cmd; +pub mod complete; pub mod opts; pub mod sink; @@ -60,7 +61,7 @@ impl fmt::Display for BuildInfo { let cc = &self.git_commit[..8]; write!( f, - " Built at: {}\n Version: {}\n Sha: {}", + "Renku CLI:\nBuilt at: {}\nVersion: {}\nSha: {}", self.build_date, self.build_version, cc ) } diff --git a/src/cli/cmd.rs b/src/cli/cmd.rs index 54bbb6b..8cb6a2a 100644 --- a/src/cli/cmd.rs +++ b/src/cli/cmd.rs @@ -8,15 +8,12 @@ pub mod userdoc; pub mod version; use super::sink::{Error as SinkError, Sink}; -use crate::cli::opts::{CommonOpts, ProxySetting}; +use crate::cli::opts::CommonOpts; use crate::data::renku_url::RenkuUrl; -use crate::httpclient::{self, Client, proxy}; +use crate::httpclient::{self, Client}; use serde::Serialize; use snafu::{ResultExt, Snafu}; -const RENKULAB_IO: &str = "https://renkulab.io"; -const ACCESS_TOKEN_ENV: &str = "RENKU_CLI_ACCESS_TOKEN"; - pub struct Context { pub opts: CommonOpts, pub client: Client, @@ -24,10 +21,7 @@ pub struct Context { impl Context { pub fn new(opts: &CommonOpts) -> Result { - let base_url = get_renku_url(opts)?; - let at = std::env::var(ACCESS_TOKEN_ENV).ok(); - let client = Client::new(base_url, proxy_settings(opts), None, false, at) - .context(ContextCreateSnafu)?; + let client = opts.create_client(None).context(ContextCreateSnafu)?; Ok(Context { opts: opts.clone(), client, @@ -51,46 +45,6 @@ impl Context { } } -fn get_renku_url(opts: &CommonOpts) -> Result { - match &opts.renku_url { - Some(u) => { - log::debug!("Use renku url from arguments: {}", u); - Ok(u.clone()) - } - None => match std::env::var("RENKU_CLI_RENKU_URL").ok() { - Some(u) => { - log::debug!("Use renku url from env RENKU_CLI_RENKU_URL: {}", u); - RenkuUrl::parse(&u).map_err(|e| CmdError::ContextCreate { - source: httpclient::Error::UrlParse { source: e }, - }) - } - None => { - log::debug!("Use renku url: https://renkulab.io"); - RenkuUrl::parse(RENKULAB_IO).map_err(|e| CmdError::ContextCreate { - source: httpclient::Error::UrlParse { source: e }, - }) - } - }, - } -} - -fn proxy_settings(opts: &CommonOpts) -> proxy::ProxySetting { - let user = opts.proxy_user.clone(); - let password = opts.proxy_password.clone(); - let prx = opts.proxy.clone(); - - log::debug!("Using proxy: {:?} @ {:?}", user, prx); - match prx { - None => proxy::ProxySetting::System, - Some(ProxySetting::None) => proxy::ProxySetting::None, - Some(ProxySetting::Custom { url }) => proxy::ProxySetting::Custom { - url: url.clone(), - user, - password, - }, - } -} - #[derive(Debug, Snafu)] pub enum CmdError { #[snafu(display("ContextCreate - {}", source))] diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index a0077d8..09d7896 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -1,9 +1,14 @@ -use crate::httpclient::{self, data::SessionStartRequest}; +use crate::{ + cli::complete::complete_job_launcher_id, + data::submission_id::SubmissionId, + httpclient::{self, data::SessionStartRequest}, +}; use super::Context; use crate::cli::sink::Error as SinkError; use clap::{Parser, ValueHint}; +use clap_complete::ArgValueCompleter; use ulid::Ulid; use snafu::{ResultExt, Snafu}; @@ -14,8 +19,23 @@ use snafu::{ResultExt, Snafu}; #[derive(Parser, Debug)] pub struct Input { /// The launcher to use for launching the job. - #[arg(value_hint=ValueHint::Other)] + #[arg(long, value_hint=ValueHint::Other, add = ArgValueCompleter::new(complete_job_launcher_id))] pub launcher: Ulid, + + /// A submission id allows to deduplicate same job submissions. If + /// missing, a random one is generated. It must be at least 4 + /// characters, starting with a lowercase letter, followed by + /// alphanumeric characters (including the dash). + #[arg(long)] + pub submission_id: Option, + + /// Overwrite the command that is set in the launcher. + #[arg(long)] + pub command: Vec, + + /// These arguments are passed to the renku job command. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0.., value_name = "ARGS")] + pub passthrough: Vec, } #[derive(Debug, Snafu)] @@ -29,9 +49,26 @@ pub enum Error { impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let submission_id = self + .submission_id + .clone() + .unwrap_or_else(SubmissionId::random); + let cmd = if self.command.is_empty() { + None + } else { + Some(self.command.clone()) + }; + let args = if self.passthrough.is_empty() { + None + } else { + Some(self.passthrough.clone()) + }; let req = SessionStartRequest { launcher_id: self.launcher.to_string(), session_type: "non-interactive".into(), + submission_id: Some(submission_id), + job_args_override: args, + job_command_override: cmd, }; let result = ctx .client diff --git a/src/cli/cmd/version.rs b/src/cli/cmd/version.rs index c5d5d13..c7876ce 100644 --- a/src/cli/cmd/version.rs +++ b/src/cli/cmd/version.rs @@ -11,14 +11,12 @@ use std::fmt; /// Prints version about server and client. /// -/// Queries the server for its version information and prints more -/// version details about this client. +/// Prints version details about this client and can also query the renku platform for its verion. #[derive(Parser, Debug, PartialEq)] pub struct Input { - /// Only show the client version and don't request server side - /// version information. + /// Also request the version on the renku platform. #[arg(long, default_value_t = false)] - pub client_only: bool, + pub with_server: bool, } #[derive(Debug, Snafu)] @@ -32,44 +30,38 @@ pub enum Error { impl Input { pub async fn exec(&self, ctx: &Context) -> Result<(), Error> { - if self.client_only { - let vinfo = BuildInfo::default(); - ctx.write_result(&vinfo).await.context(WriteResultSnafu)?; - } else { + if self.with_server { let result = ctx.client.version().await.context(HttpClientSnafu)?; - let urlstr = ctx.renku_url().as_str(); - let vinfo = Versions::create(result, urlstr); - ctx.write_result(&vinfo).await.context(WriteResultSnafu)?; + let info = Versions::create(result); + ctx.write_result(&info).await.context(WriteResultSnafu)?; + } else { + let build_info = BuildInfo::default(); + ctx.write_result(&build_info) + .await + .context(WriteResultSnafu)?; } Ok(()) } } #[derive(Debug, Serialize)] -pub struct Versions<'a> { - pub client: BuildInfo, - pub server: VersionInfo, - pub renku_url: &'a str, +pub struct Versions { + pub renku_cli: BuildInfo, + pub renku_platform: VersionInfo, } -impl Versions<'_> { - pub fn create(server: VersionInfo, renku_url: &'_ str) -> Versions<'_> { +impl Versions { + pub fn create(renku_platform: VersionInfo) -> Versions { Versions { - client: BuildInfo::default(), - server, - renku_url, + renku_cli: BuildInfo::default(), + renku_platform, } } } -impl fmt::Display for Versions<'_> { +impl fmt::Display for Versions { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let hc = &self.server.search.head_commit[..8]; - write!( - f, - "Client:\n{}\n\nRenku @ {}\n Data Services: {}\n Search Services: {} ({})", - self.client, self.renku_url, self.server.data.version, self.server.search.version, hc - ) + write!(f, "{}\n\n{}", self.renku_cli, self.renku_platform) } } -impl Sink for Versions<'_> {} +impl Sink for Versions {} diff --git a/src/cli/complete.rs b/src/cli/complete.rs new file mode 100644 index 0000000..0d87d96 --- /dev/null +++ b/src/cli/complete.rs @@ -0,0 +1,124 @@ +use std::ffi; + +use crate::{ + cli::opts::MainOpts, + data::project_id::ProjectId, + httpclient::{ + Client, + data::{SessionLauncher, SessionMode}, + }, +}; + +use clap::{Parser, builder::StyledStr, error::Error as ClapError}; +use clap_complete::CompletionCandidate; + +use super::opts::CommonOpts; + +// Helper function to create completion-candidate functions that are +// async and use the client and common options for their +// implementation. +fn make_sync_completer(current: &ffi::OsStr, func: F) -> Vec +where + F: Fn(Client, CommonOpts) -> Fut + Clone + Send + 'static, + Fut: Future> + Send + 'static, +{ + let Some(_current) = current.to_str() else { + return vec![]; + }; + + let Ok(opts) = parse_common_opts() else { + eprintln!("Completions failed: Error parsing common options"); + return vec![]; + }; + + let Ok(client) = opts.create_client(None) else { + eprintln!("Completions failed: Error creating http renku client"); + return vec![]; + }; + + tokio::task::block_in_place(move || { + tokio::runtime::Handle::current().block_on(func(client, opts)) + }) +} + +/// Parses the part of the arguments that make up CommonOpts. +fn parse_common_opts() -> Result { + // this is a bit nasty, due to lack of a better option: manually + // massage the arguments to remove everything after the first + // non-option argument appears, which is the subcommand passed to + // the binary. Then the standard command 'version' is appended, so + // that parsing succeeds. Only common-options are of interest + // here. + let mut it = std::env::args().skip(2); + let first = it.next(); + let first_it = first.map(std::iter::once).into_iter().flatten(); + let remain = it.take_while(|e| e.starts_with('-')); + // the version command to make arg parsing successful + let version = std::iter::once("version".to_string()); + let args = first_it.chain(remain).chain(version); + MainOpts::try_parse_from(args).map(|e| e.common_opts) +} + +async fn make_launcher_completion_candidate( + client: &Client, + launcher: &SessionLauncher, +) -> CompletionCandidate { + let mut help = StyledStr::new(); + help.push_str(&launcher.name); + let cc = CompletionCandidate::new(launcher.id.clone()); + + let Ok(Some(project)) = client.get_project_by_id(&launcher.project_id).await else { + eprintln!("Cannot get project details for: {}", launcher.project_id); + return cc.help(Some(help)); + }; + + help.push_str(" - "); + help.push_str(&project.name); + + cc.help(Some(help)) +} + +async fn resolve_project_id(client: &Client, id: ProjectId) -> Option { + match client.get_project(&id).await { + Ok(Some(p)) => Some(p.id), + Ok(None) => { + eprintln!("Project context not found: {}", id); + None + } + Err(msg) => { + eprintln!("Error getting project for id '{}': {}", id, msg); + None + } + } +} + +/// Complete a job session launcher id +pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { + make_sync_completer(current, async |client, opts| { + let Ok(launchers) = client.list_launchers().await else { + eprintln!("Completions failed: Error getting list of launchers"); + return vec![]; + }; + let mut result: Vec = vec![]; + let project_ctx = opts.get_project_context().ok().flatten(); + let project_id = match project_ctx { + Some(id) => resolve_project_id(&client, id).await, + None => None, + }; + for launcher in launchers + .iter() + .filter(|e| e.launcher_type == SessionMode::NonInteractive) + .filter(|e| match &project_id { + Some(id) => id == &e.project_id, + None => true, + }) + { + let cc = make_launcher_completion_candidate(&client, launcher).await; + result.push(cc); + } + if result.is_empty() { + eprintln!("No job launchers found."); + } + result + }) +} diff --git a/src/cli/opts.rs b/src/cli/opts.rs index b898c9a..c573cf9 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -1,10 +1,16 @@ -use crate::data::renku_url::RenkuUrl; +use crate::{ + data::{ + project_id::{ProjectId, ProjectIdParseError}, + renku_url::RenkuUrl, + }, + httpclient::{Client, Error as ClientError, proxy}, +}; use super::cmd::*; use clap::{Parser, ValueEnum, ValueHint}; use clap_verbosity_flag::{Verbosity, WarnLevel}; use serde::{Deserialize, Serialize}; -use std::str::FromStr; +use std::{path::PathBuf, str::FromStr}; /// Main options are available to all commands. They must appear /// before a sub-command. @@ -28,6 +34,14 @@ pub struct CommonOpts { #[arg(long, value_hint = ValueHint::Url)] pub renku_url: Option, + /// Some commands may operate within a project. If this option is + /// set, or the environment variable RENKU_CLI_PROJECT_CONTEXT is + /// present commands can use it to confine there functionality to + /// this project. The value may be the project id (ulid) or the + /// path like /. + #[arg(long, value_hint = ValueHint::Url)] + pub project_context: Option, + /// Set a proxy to use for doing http requests. By default, the /// system proxy will be used. Can be either `none` or . If /// `none`, the system proxy will be ignored; otherwise specify @@ -44,6 +58,67 @@ pub struct CommonOpts { pub proxy_password: Option, } +impl CommonOpts { + const ACCESS_TOKEN_ENV: &str = "RENKU_CLI_ACCESS_TOKEN"; + + pub fn create_client(&self, trusted_cert: Option) -> Result { + let at = std::env::var(Self::ACCESS_TOKEN_ENV).ok(); + let base_url = self + .get_renku_url() + .map_err(|e| ClientError::UrlParse { source: e })?; + Client::new(base_url, self.proxy_settings(), trusted_cert, false, at) + } + + fn proxy_settings(&self) -> proxy::ProxySetting { + let user = self.proxy_user.clone(); + let password = self.proxy_password.clone(); + let prx = self.proxy.clone(); + + log::debug!("Using proxy: {:?} @ {:?}", user, prx); + match prx { + None => proxy::ProxySetting::System, + Some(ProxySetting::None) => proxy::ProxySetting::None, + Some(ProxySetting::Custom { url }) => proxy::ProxySetting::Custom { + url: url.clone(), + user, + password, + }, + } + } + + fn get_renku_url(&self) -> Result { + match &self.renku_url { + Some(u) => { + log::debug!("Use renku url from arguments: {}", u); + Ok(u.clone()) + } + None => match RenkuUrl::from_env() { + Some(res) => { + if let Ok(u) = &res { + log::debug!("Use renku url from env RENKU_CLI_RENKU_URL: {}", u); + } + res + } + None => { + log::debug!("Use renku url: https://renkulab.io"); + Ok(RenkuUrl::renkulab_io()) + } + }, + } + } + + pub fn get_project_context(&self) -> Result, ProjectIdParseError> { + if self.project_context.is_some() { + Ok(self.project_context.clone()) + } else { + match std::env::var("RENKU_CLI_PROJECT_CONTEXT").ok() { + Some(id) => ProjectId::parse(&id).map(Some), + None => Ok(None), + } + } + } +} + #[derive(Parser, Debug)] pub enum SubCommand { #[command()] diff --git a/src/cli/sink.rs b/src/cli/sink.rs index ab295f1..fcb94e1 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -64,3 +64,4 @@ impl Sink for Response {} impl Sink for SessionStartResponse {} impl Sink for SessionList {} impl Sink for SessionLogs {} +impl Sink for VersionInfo {} diff --git a/src/data.rs b/src/data.rs index 5674cd0..d7cf8b1 100644 --- a/src/data.rs +++ b/src/data.rs @@ -7,3 +7,4 @@ Data types used across the cli pub mod project_id; pub mod renku_url; pub mod simple_message; +pub mod submission_id; diff --git a/src/data/renku_url.rs b/src/data/renku_url.rs index 7a23bc8..0b7d61c 100644 --- a/src/data/renku_url.rs +++ b/src/data/renku_url.rs @@ -4,6 +4,8 @@ use reqwest::Url; use serde::{Deserialize, Serialize}; use url::ParseError; +const RENKULAB_IO: &str = "https://renkulab.io"; + // Need this newtype to be able to implement Serialize and Deserialize #[derive(Debug, PartialEq, Clone)] @@ -32,6 +34,16 @@ impl RenkuUrl { let RenkuUrl(u) = self; u.join(seg).map(RenkuUrl) } + + pub fn from_env() -> Option> { + std::env::var("RENKU_CLI_RENKU_URL") + .ok() + .map(|u| RenkuUrl::parse(&u)) + } + + pub fn renkulab_io() -> RenkuUrl { + RenkuUrl::parse(RENKULAB_IO).unwrap() + } } impl<'de> Deserialize<'de> for RenkuUrl { diff --git a/src/data/submission_id.rs b/src/data/submission_id.rs new file mode 100644 index 0000000..fd7f9bc --- /dev/null +++ b/src/data/submission_id.rs @@ -0,0 +1,97 @@ +use std::{fmt::Display, str::FromStr}; + +use regex_macro::regex; +use serde::{Deserialize, Serialize}; + +use crate::util; + +#[derive(Debug, PartialEq, Clone)] +pub struct SubmissionId(String); + +impl SubmissionId { + pub fn parse>(input: S) -> Result { + let s = input.as_ref(); + if regex!("^[a-z][-0-9a-z]{3,19}$").is_match(s) { + Ok(SubmissionId(s.into())) + } else { + Err(SubmissionIdError::InvalidInput(s.to_string())) + } + } + + pub fn as_str(&self) -> &str { + let SubmissionId(u) = self; + u.as_str() + } + + pub fn join(&self, seg: &str) -> Result { + let SubmissionId(u) = self; + SubmissionId::parse(format!("{}{}", u, seg)) + } + + pub fn random() -> SubmissionId { + let first = util::strings::random(1, "abcdefghijklmnopqrstuvwxyz"); + let s = util::strings::random_lower_alpha_num(8); + SubmissionId(format!("{}{}", first, s)) + } +} + +impl<'de> Deserialize<'de> for SubmissionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let u = SubmissionId::parse(&s).map_err(serde::de::Error::custom)?; + Ok(u) + } +} + +impl Serialize for SubmissionId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let SubmissionId(u) = self; + serializer.serialize_str(u.as_str()) + } +} +impl Display for SubmissionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for SubmissionId { + type Err = SubmissionIdError; + + fn from_str(s: &str) -> Result { + SubmissionId::parse(s) + } +} + +#[derive(PartialEq, Eq, Clone, Debug)] +pub enum SubmissionIdError { + InvalidInput(String), +} + +impl Display for SubmissionIdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubmissionIdError::InvalidInput(msg) => write!(f, "Invalid submission id: {}", msg), + } + } +} +impl std::error::Error for SubmissionIdError {} + +#[test] +fn submission_id_parse() { + assert!(SubmissionId::parse("__-").is_err()); + assert!(SubmissionId::parse("9abcd").is_err()); + assert!(SubmissionId::parse("aBCDEFg").is_err()); + assert!(SubmissionId::parse("abc-9ed").is_ok()); + assert_eq!( + SubmissionId::parse("ab-cd-de").unwrap().as_str(), + "ab-cd-de" + ); + assert!(SubmissionId::random().as_str().len() > 4); +} diff --git a/src/httpclient.rs b/src/httpclient.rs index 17d37d2..90dbdcd 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -261,13 +261,9 @@ impl Client { /// Queries Renku for its version pub async fn version(&self) -> Result { - let data = self - .json_get::("/ui-server/api/data/version") - .await?; - let search = self - .json_get::("/ui-server/api/search/version") - .await?; - Ok(VersionInfo { search, data }) + let renku = self.json_get::("/api/data/version").await?; + let renku_url = self.base_url().clone(); + Ok(VersionInfo { renku, renku_url }) } pub async fn get_project(&self, id: &ProjectId) -> Result, Error> { @@ -301,12 +297,19 @@ impl Client { Ok(details) } + /// Get project details by a ui url. It supports two formats: + /// - /p/ + /// - /p// pub async fn get_project_by_url( &self, url: U, ) -> Result, Error> { let urlstr = url.as_str().to_string(); let url = url.into_url().context(HttpSnafu { url: urlstr })?; + let mut base = url.clone(); + base.set_path(""); + let base_url = RenkuUrl::new(base); + log::debug!("Get project by url: {}", &url); // there are different urls identifying the project // /v2/projects/ (ui) @@ -314,27 +317,12 @@ impl Client { // note the ui urls are currently not stable let project_path_regex = Regex::new( r"(?x) - (?/v2/projects/)(?[0-7][0-9A-HJKMNP-TV-Z]{25}) # /v2/projects/ (ui) + (?/p/)(?[0-7][0-9A-HJKMNP-TV-Z]{25}) # /p/ (ui) | - (?/v2/projects/)(?[^/]+)/(?.+) # /v2/projects// (ui)").unwrap(); + (?/p/)(?[^/]+)/(?.+) # /p// (ui)", + ) + .unwrap(); let captures = project_path_regex.captures(url.path()).unwrap(); - let path = if captures.name("uiproj").is_some() { - let proj_id = captures.name("uiid").unwrap().as_str(); - format!("/api/data/projects/{}", proj_id) - } else if captures.name("uinamespace").is_some() { - let namespace = captures.name("uins").unwrap().as_str(); - let proj_name = captures.name("uiname").unwrap().as_str(); - format!("/api/data/namespaces/{}/projects/{}", namespace, proj_name) - } else { - return Err(Error::ProjectUrlParse { - reason: format!("Url {} did not match project URL pattern", url.path()), - }); - }; - - log::debug!("Transformed path {} to: {}", url.path(), &path); - let mut base = url.clone(); - base.set_path(""); - let base_url = RenkuUrl::new(base); log::debug!("Create temporary client for {}", &base_url); let client = Client::new( @@ -344,8 +332,37 @@ impl Client { self.settings.accept_invalid_certs, self.access_token.clone(), )?; + if captures.name("uiproj").is_some() { + let proj_id = captures.name("uiid").unwrap().as_str(); + client.get_project_by_id(proj_id).await + } else if captures.name("uinamespace").is_some() { + let namespace = captures.name("uins").unwrap().as_str(); + let proj_name = captures.name("uiname").unwrap().as_str(); + client.get_project_by_slug(namespace, proj_name).await + } else { + Err(Error::ProjectUrlParse { + reason: format!("Url {} did not match project URL pattern", url.path()), + }) + } + } - let details = client.json_get_option::(&path).await?; + pub async fn get_namespace( + &self, + first_slug: &str, + second_slug: Option<&str>, + ) -> Result, Error> { + log::debug!( + "Get namespace by slug1/slug2: {}/{:?}", + first_slug, + second_slug + ); + let path = if let Some(second) = second_slug { + format!("/api/data/namespaces/{}/{}", first_slug, second) + } else { + format!("/api/data/namespaces/{}", first_slug) + }; + + let details = self.json_get_option::(&path).await?; Ok(details) } @@ -406,4 +423,10 @@ impl Client { cache::write_auth_token(&r).await?; Ok(r) } + + pub async fn list_launchers(&self) -> Result, Error> { + let path = "/api/data/session_launchers"; + let result = self.json_get::>(path).await?; + Ok(result) + } } diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 373fc48..22fdbfa 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -1,10 +1,28 @@ //! Defines data structures for requests and responses and their //! `De/Serialize` instances. +use crate::data::{renku_url::RenkuUrl, submission_id::SubmissionId}; use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt}; -use tabled::{Table, Tabled, settings::Style}; +use tabled::{ + Table, + builder::Builder, + settings::{Settings, Style}, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionLauncher { + pub id: String, + pub project_id: String, + pub name: String, + pub launcher_type: SessionMode, +} +impl fmt::Display for SessionLauncher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} #[derive(Debug, Serialize, Deserialize)] pub struct SessionLogs(pub HashMap); @@ -19,9 +37,11 @@ impl fmt::Display for SessionLogs { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub enum SessionMode { + #[serde(rename = "interactive")] Interactive, + #[serde(rename = "non-interactive")] NonInteractive, } @@ -44,13 +64,20 @@ impl SessionMode { pub struct SessionStartRequest { pub launcher_id: String, pub session_type: String, + pub submission_id: Option, + pub job_args_override: Option>, + pub job_command_override: Option>, } impl fmt::Display for SessionStartRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "SessionStart(launcher={}, session_type={})", - self.launcher_id, self.session_type + "SessionStart(launcher={}, session_type={}, submission_id={:?}, job_args_overrides={:?}, command={:?})", + self.launcher_id, + self.session_type, + self.submission_id, + self.job_args_override, + self.job_command_override ) } } @@ -58,31 +85,60 @@ impl fmt::Display for SessionStartRequest { #[derive(Debug, Serialize, Deserialize)] pub struct SessionList(pub Vec); +fn create_session_table<'a, I>(data: I) -> Table +where + I: IntoIterator, +{ + let mut builder = Builder::default(); + for r in data { + let sub_id = r.submission_id.as_deref().unwrap_or("-"); + let started = r.started.format(); + let data = vec![&r.name, sub_id, &r.project_id, &r.status.state, &started]; + builder.push_record(data); + } + builder.insert_record( + 0, + vec!["Job", "Submission Id", "Project Id", "Status", "Started"], + ); + + let mut table = builder.build(); + let settings = Settings::default().with(Style::sharp()); + + table.with(settings); + table +} + impl fmt::Display for SessionList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.0.is_empty() { - write!(f, "No jobs/sessions found.") + write!(f, "No jobs found.") } else { - let mut table = Table::new(&self.0); - table.with(Style::modern()); + let table = create_session_table(&self.0); write!(f, "{}", table) } } } -#[derive(Debug, Serialize, Deserialize, Tabled)] +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionStatus { + message: Option, + state: String, +} + +#[derive(Debug, Serialize, Deserialize)] pub struct SessionStartResponse { image: String, name: String, project_id: String, launcher_id: String, + submission_id: Option, + status: SessionStatus, + started: Timestamp, } impl fmt::Display for SessionStartResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut table = Table::new(vec![self]); - table.with(Style::modern()); - + let table = create_session_table(vec![self]); write!(f, "{}", table) } } @@ -123,8 +179,31 @@ pub struct SimpleVersion { /// Describes the version information provided by the renku platform. #[derive(Debug, Serialize, Deserialize)] pub struct VersionInfo { - pub search: SearchServiceVersion, - pub data: SimpleVersion, + pub renku_url: RenkuUrl, + pub renku: SimpleVersion, +} + +impl fmt::Display for VersionInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Renku Platform:\nUrl: {}\nVersion: {}", + self.renku_url, self.renku.version + ) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NamespaceDetails { + pub id: String, + pub name: String, + pub slug: String, + pub path: String, +} +impl fmt::Display for NamespaceDetails { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Namespace: {} ({})", self.path, self.id) + } } #[derive(Debug, Serialize, Deserialize)] diff --git a/src/main.rs b/src/main.rs index 8f94c75..a593c2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use color_eyre::Result as EyreResult; use rnk::cli::opts::MainOpts; use rnk::error::Result; -#[tokio::main] +#[tokio::main(flavor = "multi_thread")] async fn main() -> EyreResult<()> { rnk::error::init()?; execute().await?; diff --git a/src/util.rs b/src/util.rs index 8ffd2ca..0d8b413 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,3 +4,4 @@ Utility functions. */ pub mod file; +pub mod strings; diff --git a/src/util/strings.rs b/src/util/strings.rs new file mode 100644 index 0000000..d17ebab --- /dev/null +++ b/src/util/strings.rs @@ -0,0 +1,25 @@ +use rand::Rng; + +const CHARSET_ALPHA_NUM: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const CHARSET_LOWER_ALPHA_NUM: &str = "abcdefghijklmnopqrstuvwxyz0123456789"; +const CHARSET_ALPHA: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +pub fn random(length: usize, charset: &str) -> String { + rand::thread_rng() + .sample_iter(&rand::distributions::Uniform::from(0..charset.len())) + .take(length) + .map(|i| charset.chars().nth(i).unwrap()) + .collect() +} + +pub fn random_alpha_num(length: usize) -> String { + random(length, CHARSET_ALPHA_NUM) +} + +pub fn random_lower_alpha_num(length: usize) -> String { + random(length, CHARSET_LOWER_ALPHA_NUM) +} + +pub fn random_alpha(length: usize) -> String { + random(length, CHARSET_ALPHA) +}