diff --git a/src/blocks.rs b/src/blocks.rs index e0f8d84..f38593f 100644 --- a/src/blocks.rs +++ b/src/blocks.rs @@ -3,8 +3,12 @@ use crate::{ settings::Settings, utils::as_true, }; -use reqwest::{Method, blocking::Client}; +use reqwest::{ + Method, + blocking::{Client, Response}, +}; use serde::{Deserialize, Serialize}; +use std::cell::OnceCell; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BlockSettings { @@ -12,6 +16,13 @@ pub struct BlockSettings { pub api_url: String, #[serde(default = "Metablock::get_default_space")] pub default_space: String, + /// Organization the metablock API calls act within, by name or by id + /// + /// It is the organization owning the spaces the blocks belong to, which is + /// not necessarily named after them: the `quantmind` space is owned by the + /// `metablock` organization. + #[serde(default = "Metablock::get_default_org")] + pub org: String, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] @@ -48,6 +59,12 @@ pub struct Plugin { pub config: serde_json::Value, // Use serde_json::Value for flexible plugin configuration } +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct Org { + pub id: String, + pub short_name: String, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct Space { pub id: String, @@ -67,7 +84,10 @@ pub struct Block { pub struct Metablock { pub api_url: String, pub api_token: String, + /// Organization by name or by id, resolved to an id on first use + pub org: String, pub client: Client, + org_id: OnceCell, } impl Default for BlockSettings { @@ -75,6 +95,7 @@ impl Default for BlockSettings { Self { api_url: Metablock::get_default_api_url(), default_space: Metablock::get_default_space(), + org: Metablock::get_default_org(), } } } @@ -86,7 +107,7 @@ impl BlockSettings { "METABLOCK_API_TOKEN not set - add it to your env or the .env file".into(), ) })?; - Ok(Metablock::new(&self.api_url, api_token)) + Ok(Metablock::new(&self.api_url, api_token, &self.org)) } } @@ -100,21 +121,68 @@ impl Metablock { std::env::var("METABLOCK_SPACE").unwrap_or_else(|_| "metablock".to_string()) } - pub fn new, S2: Into>(api_url: S1, api_token: S2) -> Self { + pub fn get_default_org() -> String { + std::env::var("METABLOCK_ORG").unwrap_or_else(|_| "metablock".to_string()) + } + + pub fn new, S2: Into, S3: Into>( + api_url: S1, + api_token: S2, + org: S3, + ) -> Self { Self { api_url: api_url.into(), api_token: api_token.into(), + org: org.into(), client: Client::new(), + org_id: OnceCell::new(), } } - pub fn request(&self, method: Method, url: String) -> reqwest::blocking::RequestBuilder { + /// A request authenticated with the API key only + /// + /// Used for the endpoints resolving the organization itself, which cannot + /// require the organization header. + fn key_request(&self, method: Method, url: String) -> reqwest::blocking::RequestBuilder { self.client .request(method, url) .header("User-Agent", "quantmind/rops") .header("x-metablock-api-key", &self.api_token) } + /// A request acting within the configured organization + /// + /// The API resolves the organization from this header and answers `422` + /// when it is missing, so every block endpoint needs it. + pub fn request( + &self, + method: Method, + url: String, + ) -> RopsResult { + Ok(self + .key_request(method, url) + .header("x-metablock-org-id", self.org_id()?)) + } + + /// The id of the configured organization, fetched once and then cached + /// + /// The header only matches organizations by id, so a name from the + /// configuration has to be resolved first. + fn org_id(&self) -> RopsResult<&str> { + if let Some(org_id) = self.org_id.get() { + return Ok(org_id); + } + let url = format!("{}/v1/orgs/{}", self.api_url, self.org); + log::info!("Fetching organization information from {url}"); + let org: Org = check(self.key_request(Method::GET, url).send()?)?.json()?; + log::info!( + "Acting within organization '{}' - {}", + org.short_name, + org.id + ); + Ok(self.org_id.get_or_init(|| org.id)) + } + pub fn apply(&self, settings: &Settings, block_config: &BlockConfig) -> RopsResult<()> { let space_name = block_config .space @@ -144,7 +212,7 @@ impl Metablock { self.api_url ); log::info!("Fetching block information from {url}"); - let blocks: Vec = self.request(Method::GET, url).send()?.json()?; + let blocks: Vec = check(self.request(Method::GET, url)?.send()?)?.json()?; if blocks.is_empty() { Ok(None) } else { @@ -154,27 +222,68 @@ impl Metablock { pub fn create_block(&self, space_name: &str, block_config: &BlockConfig) -> RopsResult { let url = format!("{}/v1/spaces/{space_name}/blocks", self.api_url); - let response = self.request(Method::POST, url).json(block_config).send()?; - if response.status().is_client_error() { - return Err(RopsError::Error(format!( - "Failed to create block - status {}: {}", - response.status(), - response.text()? - ))); - } - Ok(response.json()?) + let response = self.request(Method::POST, url)?.json(block_config).send()?; + Ok(check(response)?.json()?) } pub fn update_block(&self, block_id: &str, block_config: &BlockConfig) -> RopsResult { let url = format!("{}/v1/blocks/{block_id}", self.api_url); - let response = self.request(Method::PATCH, url).json(block_config).send()?; - if response.status().is_client_error() { - return Err(RopsError::Error(format!( - "Failed to update block - status {}: {}", - response.status(), - response.text()? - ))); - } - Ok(response.json()?) + let response = self + .request(Method::PATCH, url)? + .json(block_config) + .send()?; + Ok(check(response)?.json()?) + } +} + +/// Report an error response by its status and body +/// +/// Decoding an error response would fail with reqwest's opaque "error decoding +/// response body", losing both the status and the message from the API. +fn check(response: Response) -> RopsResult { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let url = response.url().to_string(); + Err(RopsError::Error(format!( + "{status} from {url}: {}", + response.text()? + ))) +} + +#[cfg(test)] +mod tests { + use super::{Block, Org}; + + // Responses from the live API, the fields the structs do not use removed + + #[test] + fn org_is_deserialized() { + let org: Org = serde_json::from_str( + r#"{"email":"admin@metablock.io","short_name":"metablock","full_name":"", + "status":"created","id":"63b97d659eb7487c9cb287d68fcbb38a", + "created":"2025-04-10T09:55:39.494335Z","additional_info":{}}"#, + ) + .unwrap(); + assert_eq!(org.short_name, "metablock"); + assert_eq!(org.id, "63b97d659eb7487c9cb287d68fcbb38a"); + } + + #[test] + fn block_list_is_deserialized() { + let blocks: Vec = serde_json::from_str( + r#"[{"id":"2a9a109290da48d9ae611793d812e5e6", + "service_id":"b66516e371dd488d909cb55cb41cfcb6","name":"code", + "space":{"cdn":"","hosted":true,"name":"quantmind", + "domain":"quantmind.com","id":"1904c2c1b2304672a98df556d4773f27", + "org_id":"63b97d659eb7487c9cb287d68fcbb38a","org_name":""}, + "full_name":"code-quantmind","html":false,"root":false,"acme":true, + "domain":"code.quantmind.com","url":"https://code.quantmind.com"}]"#, + ) + .unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].full_name, "code-quantmind"); + assert_eq!(blocks[0].space.name, "quantmind"); } } diff --git a/src/git.rs b/src/git.rs index 566d8f5..091298f 100644 --- a/src/git.rs +++ b/src/git.rs @@ -28,6 +28,8 @@ pub struct GithubDownloadRelease { pub version: Option, /// A different download url pub download_url: Option, + /// Maps a release tag to the version used in asset file names + pub version_fn: Option String>, } #[derive(Clone, Debug)] @@ -151,6 +153,7 @@ impl GithubDownloadRelease { token: GitSettings::get_github_token(), version: None, download_url: None, + version_fn: None, } } @@ -164,6 +167,20 @@ impl GithubDownloadRelease { self } + pub fn with_version_fn(mut self, version_fn: fn(&str) -> String) -> Self { + self.version_fn = Some(version_fn); + self + } + + /// The version used in asset file names. Without a callback the release tag + /// is used as is. + pub fn get_version(&self, tag: &str) -> String { + match self.version_fn { + Some(version_fn) => version_fn(tag), + None => tag.to_string(), + } + } + pub fn request(&self, url: String) -> reqwest::blocking::RequestBuilder { let mut builder = self.client.get(url).header("User-Agent", "quantmind/rops"); if let Some(ref token) = self.token { @@ -237,7 +254,7 @@ impl GithubDownloadRelease { pub fn get_file_name(&self, settings: &Settings, release: &Release, arch: &str) -> String { self.file_name - .replace("{version}", &release.tag_name) + .replace("{version}", &self.get_version(&release.tag_name)) .replace("{os}", &settings.system.os) .replace("{arch}", arch) } @@ -271,3 +288,23 @@ impl GithubDownloadRelease { Ok(asset) } } + +#[cfg(test)] +mod tests { + use super::GithubDownloadRelease; + + fn release() -> GithubDownloadRelease { + GithubDownloadRelease::new("owner/repo", "tool-{version}-{os}-{arch}.tar.gz") + } + + #[test] + fn version_is_the_tag_without_a_callback() { + assert_eq!(release().get_version("v3.16.1"), "v3.16.1"); + } + + #[test] + fn version_is_mapped_by_the_callback() { + let release = release().with_version_fn(|tag| tag.replace("release-", "")); + assert_eq!(release.get_version("release-3.16.1"), "3.16.1"); + } +} diff --git a/src/tools.rs b/src/tools.rs index 16c15bd..b4fbb8e 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -25,6 +25,16 @@ struct Tools { tools: HashMap, // tool name and version } +/// Drops everything before the first digit of a release tag, so that a tag such +/// as `desktop-v0.5.7` matches an asset named after the bare `0.5.7`. Tags +/// without a digit are left alone. +fn strip_tag_prefix(tag: &str) -> String { + match tag.find(|c: char| c.is_ascii_digit()) { + Some(index) => tag[index..].to_string(), + None => tag.to_string(), + } +} + enum InstallMethod { GithubDownload(GithubDownloadRelease), } @@ -63,6 +73,14 @@ impl Default for Tools { fn default() -> Self { Self { tools: vec![ + ThirdPartyTool::new( + "buzz", + "A workspace where humans and agents build together", + InstallMethod::GithubDownload( + GithubDownloadRelease::new("block/buzz", "buzz_{version}_{arch}.appimage") + .with_version_fn(strip_tag_prefix), + ), + ), ThirdPartyTool::new( "helm", "The Kubernetes Package Manager", @@ -171,3 +189,24 @@ impl ThirdPartyTool { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::strip_tag_prefix; + + #[test] + fn strips_a_prefixed_tag() { + assert_eq!(strip_tag_prefix("desktop-v0.5.7"), "0.5.7"); + assert_eq!(strip_tag_prefix("v3.16.1"), "3.16.1"); + } + + #[test] + fn leaves_a_bare_version_alone() { + assert_eq!(strip_tag_prefix("0.5.7"), "0.5.7"); + } + + #[test] + fn leaves_a_tag_without_digits_alone() { + assert_eq!(strip_tag_prefix("sprig-latest"), "sprig-latest"); + } +}