diff --git a/Cargo.lock b/Cargo.lock index 599a4d9..99d5b61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2920,6 +2920,7 @@ dependencies = [ "tokio", "tokio-util", "toml 0.8.23", + "ulid", "url", "vergen", "walkdir", @@ -4017,6 +4018,16 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "web-time", +] + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index ac47d42..040b420 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ self_update = { version = "0.43.1", features = [ "compression-flate2", ] } md5 = "0.8.0" +ulid = "1.2.1" [features] default = ["reqwest/default-tls"] # link against system library diff --git a/flake.nix b/flake.nix index e7990e0..de861de 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,6 @@ crane = { url = "github:ipetkov/crane"; - inputs.nixpkgs.follows = "nixpkgs"; }; fenix = { @@ -42,12 +41,14 @@ craneLib = crane.mkLib pkgs; src = craneLib.cleanCargoSource ./.; docSrc = ./docs; + version = "0.0.0"; fenixToolChain = fenix.packages.${system}.complete; # Common arguments can be set here to avoid repeating them later commonArgs = { inherit src; + inherit version; strictDeps = true; nativeBuildInputs = [ @@ -128,17 +129,20 @@ # Check formatting my-crate-fmt = craneLib.cargoFmt { inherit src; + inherit version; }; # Audit dependencies my-crate-audit = craneLib.cargoAudit { inherit src advisory-db; cargoAuditExtraArgs = "--ignore RUSTSEC-2023-0071"; + inherit version; }; # Audit licenses my-crate-deny = craneLib.cargoDeny { inherit src; + inherit version; }; # Run tests with cargo-nextest diff --git a/src/cli.rs b/src/cli.rs index 17b8f12..66e1bd2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,6 +27,7 @@ pub async fn execute_cmd(opts: MainOpts) -> Result<(), CmdError> { SubCommand::UserDoc(input) => input.exec(ctx).await?, SubCommand::Dataset(input) => input.exec(ctx).await?, + SubCommand::Job(input) => input.exec(ctx).await?, }; Ok(()) } diff --git a/src/cli/cmd.rs b/src/cli/cmd.rs index 0750340..54bbb6b 100644 --- a/src/cli/cmd.rs +++ b/src/cli/cmd.rs @@ -1,4 +1,5 @@ pub mod dataset; +pub mod job; pub mod login; pub mod project; pub mod update; @@ -113,6 +114,15 @@ pub enum CmdError { #[snafu(display("Dataset - {}", source))] Dataset { source: dataset::Error }, + + #[snafu(display("Job - {}", source))] + Job { source: job::Error }, +} + +impl From for CmdError { + fn from(source: job::Error) -> Self { + CmdError::Job { source } + } } impl From for CmdError { diff --git a/src/cli/cmd/job.rs b/src/cli/cmd/job.rs new file mode 100644 index 0000000..3b40ec5 --- /dev/null +++ b/src/cli/cmd/job.rs @@ -0,0 +1,56 @@ +pub mod list; +pub mod logs; +pub mod start; +pub mod stop; + +use super::Context; +use clap::Parser; +use snafu::{ResultExt, Snafu}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error starting job: {}", source))] + Start { source: start::Error }, + + #[snafu(display("Error stopping job: {}", source))] + Stop { source: stop::Error }, + + #[snafu(display("Error listing jobs: {}", source))] + List { source: list::Error }, + + #[snafu(display("Error getting logs: {}", source))] + Logs { source: logs::Error }, +} + +/// Sub command for managing projects +#[derive(Parser, Debug)] +pub struct Input { + #[command(subcommand)] + pub subcmd: JobCommand, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + match &self.subcmd { + JobCommand::Start(input) => input.exec(ctx).await.context(StartSnafu), + JobCommand::Stop(input) => input.exec(ctx).await.context(StopSnafu), + JobCommand::List(input) => input.exec(ctx).await.context(ListSnafu), + JobCommand::Logs(input) => input.exec(ctx).await.context(LogsSnafu), + } + } +} + +#[derive(Parser, Debug)] +pub enum JobCommand { + #[command()] + Start(start::Input), + + #[command()] + Stop(stop::Input), + + #[command()] + List(list::Input), + + #[command()] + Logs(logs::Input), +} diff --git a/src/cli/cmd/job/list.rs b/src/cli/cmd/job/list.rs new file mode 100644 index 0000000..c3a8cb8 --- /dev/null +++ b/src/cli/cmd/job/list.rs @@ -0,0 +1,36 @@ +use super::Context; +use crate::{ + cli::sink::Error as SinkError, + httpclient::{self, data::SessionMode}, +}; + +use clap::Parser; + +use snafu::{ResultExt, Snafu}; + +/// Listing jobs. +/// +/// List currently running jobs. +#[derive(Parser, Debug)] +pub struct Input {} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let result = ctx + .client + .list_sessions(Some(SessionMode::NonInteractive)) + .await + .context(HttpClientSnafu)?; + + ctx.write_result(&result).await.context(WriteResultSnafu) + } +} diff --git a/src/cli/cmd/job/logs.rs b/src/cli/cmd/job/logs.rs new file mode 100644 index 0000000..6cc4bb4 --- /dev/null +++ b/src/cli/cmd/job/logs.rs @@ -0,0 +1,48 @@ +use super::Context; +use crate::{cli::sink::Error as SinkError, data::simple_message::SimpleMessage, httpclient}; + +use clap::{Parser, ValueHint}; + +use snafu::{ResultExt, Snafu}; + +/// Listing logs of a jobs. +/// +/// List the logs of a job. +#[derive(Parser, Debug)] +pub struct Input { + #[arg(value_hint=ValueHint::Other)] + pub job_id: String, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let result = ctx + .client + .session_logs(&self.job_id) + .await + .context(HttpClientSnafu)?; + + if let Some(lines) = result.0.get("amalthea-session") { + ctx.write_result(&SimpleMessage { + message: lines.to_string(), + }) + .await + .context(WriteResultSnafu) + } else { + ctx.write_result(&SimpleMessage { + message: "No logs available.".to_string(), + }) + .await + .context(WriteResultSnafu) + } + } +} diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs new file mode 100644 index 0000000..a0077d8 --- /dev/null +++ b/src/cli/cmd/job/start.rs @@ -0,0 +1,44 @@ +use crate::httpclient::{self, data::SessionStartRequest}; + +use super::Context; +use crate::cli::sink::Error as SinkError; + +use clap::{Parser, ValueHint}; +use ulid::Ulid; + +use snafu::{ResultExt, Snafu}; + +/// Start a job. +/// +/// Starts a non-interactive session using a pre-configured session launcher. +#[derive(Parser, Debug)] +pub struct Input { + /// The launcher to use for launching the job. + #[arg(value_hint=ValueHint::Other)] + pub launcher: Ulid, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let req = SessionStartRequest { + launcher_id: self.launcher.to_string(), + session_type: "non-interactive".into(), + }; + let result = ctx + .client + .start_session(req) + .await + .context(HttpClientSnafu)?; + + ctx.write_result(&result).await.context(WriteResultSnafu) + } +} diff --git a/src/cli/cmd/job/stop.rs b/src/cli/cmd/job/stop.rs new file mode 100644 index 0000000..de94a4d --- /dev/null +++ b/src/cli/cmd/job/stop.rs @@ -0,0 +1,41 @@ +use crate::{data::simple_message::SimpleMessage, httpclient}; + +use super::Context; +use crate::cli::sink::Error as SinkError; + +use clap::{Parser, ValueHint}; + +use snafu::{ResultExt, Snafu}; + +/// Stop a job. +/// +/// Stop a running non-interactive session. +#[derive(Parser, Debug)] +pub struct Input { + /// The launcher to use for launching the job. + #[arg(value_hint=ValueHint::Other)] + pub job_id: String, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + ctx.client + .stop_session(&self.job_id) + .await + .context(HttpClientSnafu)?; + ctx.write_result(&SimpleMessage { + message: "Job is being removed.".into(), + }) + .await + .context(WriteResultSnafu) + } +} diff --git a/src/cli/cmd/project/clone.rs b/src/cli/cmd/project/clone.rs index 2aaa8f4..b847f60 100644 --- a/src/cli/cmd/project/clone.rs +++ b/src/cli/cmd/project/clone.rs @@ -67,10 +67,7 @@ impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { let opt_details = ctx .client - .get_project( - &self.project_ref, - ctx.opts.verbosity.log_level().unwrap_or(log::Level::Warn) > log::Level::Info, - ) + .get_project(&self.project_ref) .await .context(HttpClientSnafu)?; if let Some(details) = opt_details { diff --git a/src/cli/cmd/version.rs b/src/cli/cmd/version.rs index 917df2d..c5d5d13 100644 --- a/src/cli/cmd/version.rs +++ b/src/cli/cmd/version.rs @@ -36,13 +36,7 @@ impl Input { let vinfo = BuildInfo::default(); ctx.write_result(&vinfo).await.context(WriteResultSnafu)?; } else { - let result = ctx - .client - .version( - ctx.opts.verbosity.log_level().unwrap_or(log::Level::Warn) > log::Level::Info, - ) - .await - .context(HttpClientSnafu)?; + 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)?; diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 9b87f16..b898c9a 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -66,6 +66,9 @@ pub enum SubCommand { #[command()] Dataset(dataset::Input), + + #[command()] + Job(job::Input), } /// This is the command line interface to the Renku platform. Main diff --git a/src/cli/sink.rs b/src/cli/sink.rs index 5bd9066..ab295f1 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -61,3 +61,6 @@ impl Sink for BuildInfo {} impl Sink for PathEntry {} impl Sink for UserCode {} impl Sink for Response {} +impl Sink for SessionStartResponse {} +impl Sink for SessionList {} +impl Sink for SessionLogs {} diff --git a/src/httpclient.rs b/src/httpclient.rs index cf23c87..17d37d2 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -16,7 +16,7 @@ //! None, //! ).unwrap(); //! async { -//! println!("{:?}", client.version(false).await); +//! println!("{:?}", client.version().await); //! }; //! ``` //! @@ -37,18 +37,40 @@ use auth::{Response, UserCode}; use openidconnect::OAuth2TokenResponse; use regex::Regex; use reqwest::{Certificate, ClientBuilder, IntoUrl, RequestBuilder, Url}; -use serde::de::DeserializeOwned; +use serde::{Serialize, de::DeserializeOwned}; use snafu::{ResultExt, Snafu}; use std::path::PathBuf; const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); +fn display_bad_response(em: &Option, body: &String) -> String { + match em { + Some(s) => match &s.error { + Some(em) => em.message.to_owned(), + None => s.message.to_owned().unwrap_or(body.to_owned()), + }, + None => body.to_owned(), + } +} + #[derive(Debug, Snafu)] #[snafu(visibility(pub(crate)))] pub enum Error { #[snafu(display("An error was received from {}: {}", url, source))] Http { source: reqwest::Error, url: String }, + #[snafu(display( + "Response not successful: {} - {}", + status, + display_bad_response(err_message, body) + ))] + BadResponse { + status: reqwest::StatusCode, + body: String, + url: String, + err_message: Option, + }, + #[snafu(display("An error occurred creating the http client: {}", source))] ClientCreate { source: reqwest::Error }, @@ -163,84 +185,99 @@ impl Client { } } + async fn run_request( + &self, + req: RequestBuilder, + url: Url, + ) -> Result { + log::debug!("Run request: {}", url); + let resp = req.send().await.context(HttpSnafu { url: url.clone() })?; + + let status = resp.status(); + let body = resp.text().await.context(DeserializeRespSnafu)?; + log::debug!("Response: {} -> {}", url, body); + if status.is_success() { + serde_json::from_str::(&body).context(DeserializeJsonSnafu) + } else { + let err_resp = serde_json::from_str::(&body).ok(); + Err(Error::BadResponse { + status, + body, + url: url.to_string(), + err_message: err_resp, + }) + } + } + /// Runs a GET request to the given url. When `debug` is true, the /// response is first decoded into utf8 chars and logged at debug /// level. Otherwise bytes are directly decoded from JSON into the /// expected structure. - async fn json_get(&self, path: &str, debug: bool) -> Result { + async fn json_get(&self, path: &str) -> Result { let url = self.make_url(path)?; log::debug!("JSON GET: {}", url); - let resp = self - .set_bearer_token(self.client.get(url.clone())) - .send() - .await - .context(HttpSnafu { url: url.clone() })?; - if debug { - let body = resp.text().await.context(DeserializeRespSnafu)?; - log::debug!("GET {} -> {}", url, body); - serde_json::from_str::(&body).context(DeserializeJsonSnafu) - } else { - resp.json::().await.context(DeserializeRespSnafu) - } + let req = self.set_bearer_token(self.client.get(url.clone())); + self.run_request(req, url).await + } + + /// Runs a POST request to the given url. + async fn json_post( + &self, + path: &str, + body: &I, + ) -> Result { + let url = self.make_url(path)?; + let req = self + .set_bearer_token(self.client.post(url.clone())) + .json::(body); + self.run_request(req, url).await } /// Runs a GET request to the given url. When `debug` is true, the /// response is first decoded into utf8 chars and logged at debug /// level. Otherwise bytes are directly decoded from JSON into the /// expected structure. - async fn json_get_option( - &self, - path: &str, - debug: bool, - ) -> Result, Error> { + async fn json_get_option(&self, path: &str) -> Result, Error> { let url = self.make_url(path)?; - let resp = self - .set_bearer_token(self.client.get(url.clone())) - .send() - .await - .context(HttpSnafu { url: url.clone() })?; - - if debug { - if resp.status() == reqwest::StatusCode::NOT_FOUND { - log::debug!("GET {} -> NotFound", &url); - Ok(None) - } else { - let body = &resp.text().await.context(DeserializeRespSnafu)?; - log::debug!("GET {} -> {}", &url, body); - let r = serde_json::from_str::(body).context(DeserializeJsonSnafu)?; - Ok(Some(r)) + let req = self.set_bearer_token(self.client.get(url.clone())); + + let result = self.run_request(req, url).await; + match result { + Err(Error::BadResponse { + status, + body: _, + url: _, + err_message: _, + }) => { + if status == reqwest::StatusCode::NOT_FOUND { + Ok(None) + } else { + result + } } - } else if resp.status() == reqwest::StatusCode::NOT_FOUND { - Ok(None) - } else { - let r = resp.json::().await.context(DeserializeRespSnafu)?; - Ok(Some(r)) + _ => result, } } /// Queries Renku for its version - pub async fn version(&self, debug: bool) -> Result { + pub async fn version(&self) -> Result { let data = self - .json_get::("/ui-server/api/data/version", debug) + .json_get::("/ui-server/api/data/version") .await?; let search = self - .json_get::("/ui-server/api/search/version", debug) + .json_get::("/ui-server/api/search/version") .await?; Ok(VersionInfo { search, data }) } - pub async fn get_project( - &self, - id: &ProjectId, - debug: bool, - ) -> Result, Error> { + pub async fn get_project(&self, id: &ProjectId) -> Result, Error> { match id { ProjectId::NamespaceSlug { namespace, slug } => { - self.get_project_by_slug(namespace, slug, debug).await + self.get_project_by_slug(namespace, slug).await } - ProjectId::Id(pid) => self.get_project_by_id(pid, debug).await, + ProjectId::Id(pid) => self.get_project_by_id(pid).await, - ProjectId::FullUrl(url) => self.get_project_by_url(url.as_url().clone(), debug).await, + ProjectId::FullUrl(url) => self.get_project_by_url(url.as_url().clone()).await, } } @@ -249,30 +286,24 @@ impl Client { &self, namespace: &str, slug: &str, - debug: bool, ) -> Result, Error> { log::debug!("Get project by namespace/slug: {}/{}", namespace, slug); let path = format!("/api/data/namespaces/{}/projects/{}", namespace, slug); - let details = self.json_get_option::(&path, debug).await?; + let details = self.json_get_option::(&path).await?; Ok(details) } /// Get project details by project id. - pub async fn get_project_by_id( - &self, - id: &str, - debug: bool, - ) -> Result, Error> { + pub async fn get_project_by_id(&self, id: &str) -> Result, Error> { log::debug!("Get project by id: {}", id); let path = format!("/api/data/projects/{}", id); - let details = self.json_get_option::(&path, debug).await?; + let details = self.json_get_option::(&path).await?; Ok(details) } pub async fn get_project_by_url( &self, url: U, - debug: bool, ) -> Result, Error> { let urlstr = url.as_str().to_string(); let url = url.into_url().context(HttpSnafu { url: urlstr })?; @@ -314,12 +345,57 @@ impl Client { self.access_token.clone(), )?; - let details = client - .json_get_option::(&path, debug) + let details = client.json_get_option::(&path).await?; + Ok(details) + } + + pub async fn start_session( + &self, + req: SessionStartRequest, + ) -> Result { + log::debug!("Starting session: {}", req); + + let path = "/api/data/sessions"; + let details = self + .json_post::(path, &req) .await?; Ok(details) } + pub async fn stop_session(&self, session_id: &str) -> Result<(), Error> { + log::debug!("Stop session: {}", session_id); + let path = format!("/api/data/sessions/{}", session_id); + let url = self.make_url(&path)?; + self.set_bearer_token(self.client.delete(url.clone())) + .send() + .await + .context(HttpSnafu { url })?; + Ok(()) + } + + pub async fn list_sessions(&self, mode: Option) -> Result { + let url = self.make_url("/api/data/sessions")?; + log::debug!( + "List sessions: {}?session_mode={}", + url, + mode.as_ref().map_or("", |e| e.to_query_param()) + ); + let mut req = self.set_bearer_token(self.client.get(url.clone())); + if let Some(m) = mode { + req = req.query(&[("session_type", m.to_query_param())]) + } + + self.run_request::>(req, url) + .await + .map(SessionList) + } + + pub async fn session_logs(&self, session_id: &str) -> Result { + let path = format!("/api/data/sessions/{}/logs", session_id); + let result = self.json_get::(&path).await?; + Ok(result) + } + pub async fn start_login_flow(&self) -> Result { let c = auth::get_user_code(self.settings.base_url.clone()).await?; Ok(c) diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index febf42a..373fc48 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -3,7 +3,89 @@ use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; -use std::fmt; +use std::{collections::HashMap, fmt}; +use tabled::{Table, Tabled, settings::Style}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionLogs(pub HashMap); + +impl fmt::Display for SessionLogs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (k, v) in &self.0 { + writeln!(f, "- {}", k)?; + write!(f, "{}", v)?; + } + write!(f, "") + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SessionMode { + Interactive, + NonInteractive, +} + +impl fmt::Display for SessionMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_query_param()) + } +} + +impl SessionMode { + pub fn to_query_param(&self) -> &str { + match self { + SessionMode::Interactive => "interactive", + SessionMode::NonInteractive => "non-interactive", + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionStartRequest { + pub launcher_id: String, + pub session_type: String, +} +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 + ) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionList(pub Vec); + +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.") + } else { + let mut table = Table::new(&self.0); + table.with(Style::modern()); + write!(f, "{}", table) + } + } +} + +#[derive(Debug, Serialize, Deserialize, Tabled)] +pub struct SessionStartResponse { + image: String, + name: String, + project_id: String, + launcher_id: String, +} + +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()); + + write!(f, "{}", table) + } +} #[derive(Debug, Serialize, Deserialize)] pub enum Visibility { @@ -71,3 +153,44 @@ impl fmt::Display for ProjectDetails { ) } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct RenkuError { + pub code: i32, + pub message: String, +} + +impl fmt::Display for RenkuError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error: {} - {}", self.code, self.message) + } +} + +/// Error response can be either a concrete renku error, or an error +/// from the proxy/gateway then there is only a message field. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: Option, + pub message: Option, +} + +impl ErrorResponse { + pub fn code(&self) -> Option { + self.error.as_ref().map(|em| em.code) + } +} + +impl fmt::Display for ErrorResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.error { + Some(re) => write!(f, "{}", re), + None => { + if let Some(m) = &self.message { + write!(f, "{}", m) + } else { + write!(f, "No error message.") + } + } + } + } +}