Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 132 additions & 23 deletions src/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,26 @@ 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 {
#[serde(default = "Metablock::get_default_api_url")]
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)]
Expand Down Expand Up @@ -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,
Expand All @@ -67,14 +84,18 @@ 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<String>,
}

impl Default for BlockSettings {
fn default() -> Self {
Self {
api_url: Metablock::get_default_api_url(),
default_space: Metablock::get_default_space(),
org: Metablock::get_default_org(),
}
}
}
Expand All @@ -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))
}
}

Expand All @@ -100,21 +121,68 @@ impl Metablock {
std::env::var("METABLOCK_SPACE").unwrap_or_else(|_| "metablock".to_string())
}

pub fn new<S1: Into<String>, S2: Into<String>>(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<S1: Into<String>, S2: Into<String>, S3: Into<String>>(
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<reqwest::blocking::RequestBuilder> {
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
Expand Down Expand Up @@ -144,7 +212,7 @@ impl Metablock {
self.api_url
);
log::info!("Fetching block information from {url}");
let blocks: Vec<Block> = self.request(Method::GET, url).send()?.json()?;
let blocks: Vec<Block> = check(self.request(Method::GET, url)?.send()?)?.json()?;
if blocks.is_empty() {
Ok(None)
} else {
Expand All @@ -154,27 +222,68 @@ impl Metablock {

pub fn create_block(&self, space_name: &str, block_config: &BlockConfig) -> RopsResult<Block> {
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<Block> {
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<Response> {
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<Block> = 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");
}
}
39 changes: 38 additions & 1 deletion src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub struct GithubDownloadRelease {
pub version: Option<String>,
/// A different download url
pub download_url: Option<String>,
/// Maps a release tag to the version used in asset file names
pub version_fn: Option<fn(&str) -> String>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -151,6 +153,7 @@ impl GithubDownloadRelease {
token: GitSettings::get_github_token(),
version: None,
download_url: None,
version_fn: None,
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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");
}
}
39 changes: 39 additions & 0 deletions src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ struct Tools {
tools: HashMap<String, ThirdPartyTool>, // 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),
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
}
}
Loading