diff --git a/Cargo.toml b/Cargo.toml index 0ea02b9a7..cd5f82fe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -202,6 +202,7 @@ uuid = "1" hostname = "0.4" walkdir = "2" workspace_root = "0.1" +zip = { version = "2", default-features = false, features = ["deflate"] } pem = "3.0" oci-tar-builder = "0.4" sha2 = "0.10" diff --git a/crates/alien-cli/Cargo.toml b/crates/alien-cli/Cargo.toml index 21acf377d..cdbfd8a21 100644 --- a/crates/alien-cli/Cargo.toml +++ b/crates/alien-cli/Cargo.toml @@ -78,6 +78,7 @@ rand = { workspace = true } rand_distr = { workspace = true } reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls-webpki-roots"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } +zip = { workspace = true } axum = { workspace = true, features = ["tokio", "http2", "query"] } # Push-mode debug tunnel: loopback proxy dials the manager via WebSocket # and forwards cloud-CLI HTTP requests. diff --git a/crates/alien-cli/src/commands/mod.rs b/crates/alien-cli/src/commands/mod.rs index f3c9bdde3..48550a79d 100644 --- a/crates/alien-cli/src/commands/mod.rs +++ b/crates/alien-cli/src/commands/mod.rs @@ -25,6 +25,11 @@ pub mod platform; #[cfg(feature = "platform")] pub mod manager; +/// Operations plugin commands (publish, list). Talk to the platform API, so +/// only available when the `platform` feature is enabled. +#[cfg(feature = "platform")] +pub mod operations; + pub use build::{build_command, BuildArgs, BuildSubcommand}; pub use commands::{commands_task, commands_task_dev, CommandsArgs}; pub use debug::{debug_task, debug_task_dev, DebugArgs}; diff --git a/crates/alien-cli/src/commands/operations.rs b/crates/alien-cli/src/commands/operations.rs new file mode 100644 index 000000000..0bdf22805 --- /dev/null +++ b/crates/alien-cli/src/commands/operations.rs @@ -0,0 +1,305 @@ +//! CLI commands for operations plugins. +//! +//! Operations plugins package named operations (`plugin/operation`) that run +//! inside a deployment via the commands interface. `publish` uploads a custom +//! plugin bundle (a ZIP with `metadata.json` + per-arch binaries) to the +//! platform so a workspace can use its operations; `list` shows the catalog. +//! +//! Platform-gated: these talk to the Alien platform API, not a standalone +//! manager. + +use std::io::Read; +use std::path::PathBuf; + +use alien_error::{AlienError, Context, IntoAlienError}; +use base64::Engine as _; +use clap::{Parser, Subcommand}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{ErrorData, Result}; +use crate::execution_context::ExecutionMode; + +#[derive(Parser, Debug, Clone)] +#[command( + about = "Manage operations plugins", + long_about = "Manage operations plugins. + +Operations plugins package named operations you can run inside a deployment via +the commands interface (`plugin/operation`). Publish a custom bundle to make its +operations available in your workspace. + +EXAMPLES: + # Publish a custom plugin bundle + alien operations publish ./postgres-operations-1.0.0.zip + + # List available plugins (builtin + custom) + alien operations list +" +)] +pub struct OperationsArgs { + #[command(subcommand)] + pub action: OperationsAction, + + /// Emit machine-readable JSON. + #[arg(long, global = true)] + pub json: bool, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum OperationsAction { + /// Publish a custom operations plugin bundle (ZIP) to your workspace. + Publish { + /// Path to the plugin bundle ZIP (contains metadata.json + binaries). + bundle: PathBuf, + }, + /// List available operations plugins (builtin + custom). + List, +} + +/// The `metadata.json` fields the CLI reads to describe the bundle. The full +/// object is forwarded to the platform, which validates it authoritatively. +#[derive(Debug, Deserialize)] +struct BundleMetadata { + name: String, + version: String, + #[serde(default)] + tier: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublishRequest { + name: String, + version: String, + tier: String, + /// The full, verbatim metadata.json object (platform re-validates it). + metadata: Value, + /// The bundle ZIP, base64-encoded. + bundle_base64: String, +} + +pub async fn operations_task(args: OperationsArgs, ctx: ExecutionMode) -> Result<()> { + let auth = ctx.auth_http().await?; + let workspace = ctx.resolve_workspace_with_bootstrap(!args.json).await?; + + match args.action { + OperationsAction::Publish { bundle } => { + publish_task(&auth, &workspace, &bundle, args.json).await + } + OperationsAction::List => list_task(&auth, &workspace, args.json).await, + } +} + +/// Read the bundle, extract + validate its metadata, and POST it to the +/// platform. Base64-inlines the ZIP so no multipart handling is needed. +async fn publish_task( + auth: &crate::auth::AuthHttp, + workspace: &str, + bundle_path: &PathBuf, + json: bool, +) -> Result<()> { + let bytes = std::fs::read(bundle_path) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("could not read bundle '{}'", bundle_path.display()), + })?; + + let (metadata_value, metadata) = read_bundle_metadata(&bytes, bundle_path)?; + let tier = metadata.tier.clone().unwrap_or_else(|| "destructive".to_string()); + + let request = PublishRequest { + name: metadata.name.clone(), + version: metadata.version.clone(), + tier, + metadata: metadata_value, + bundle_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + }; + + let url = api_url(&auth.base_url, "/v1/operations/plugins", workspace)?; + let response = auth + .reqwest_client() + .request(Method::POST, url.clone()) + .json(&request) + .send() + .await + .into_alien_error() + .context(ErrorData::ApiRequestFailed { + message: "publishing operations plugin".to_string(), + url: Some(url.to_string()), + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AlienError::new(ErrorData::ApiRequestFailed { + message: format!("publish failed ({status}): {body}"), + url: Some(url.to_string()), + })); + } + + if json { + let body: Value = response.json().await.unwrap_or(Value::Null); + println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default()); + } else { + println!( + "Published plugin '{}' v{} to workspace '{}'.", + metadata.name, metadata.version, workspace + ); + } + Ok(()) +} + +async fn list_task( + auth: &crate::auth::AuthHttp, + workspace: &str, + json: bool, +) -> Result<()> { + let url = api_url(&auth.base_url, "/v1/operations/plugins", workspace)?; + let response = auth + .reqwest_client() + .request(Method::GET, url.clone()) + .send() + .await + .into_alien_error() + .context(ErrorData::ApiRequestFailed { + message: "listing operations plugins".to_string(), + url: Some(url.to_string()), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AlienError::new(ErrorData::ApiRequestFailed { + message: format!("list failed ({status}): {body}"), + url: Some(url.to_string()), + })); + } + + let body: Value = response.json().await.unwrap_or(Value::Null); + if json { + println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default()); + } else { + let plugins = body.get("plugins").and_then(Value::as_array); + match plugins { + Some(plugins) if !plugins.is_empty() => { + for p in plugins { + let name = p.get("name").and_then(Value::as_str).unwrap_or("?"); + let version = p.get("version").and_then(Value::as_str).unwrap_or("?"); + let builtin = p.get("builtin").and_then(Value::as_bool).unwrap_or(false); + let tier = p.get("tier").and_then(Value::as_str).unwrap_or("?"); + let kind = if builtin { "builtin" } else { "custom" }; + println!("{name} v{version} [{kind}, {tier}]"); + } + } + _ => println!("No operations plugins available."), + } + } + Ok(()) +} + +/// Extract and validate `metadata.json` from the bundle ZIP. Returns the raw +/// JSON value (forwarded verbatim) plus the fields the CLI needs. +fn read_bundle_metadata(bytes: &[u8], path: &PathBuf) -> Result<(Value, BundleMetadata)> { + let reader = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(reader) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("'{}' is not a valid ZIP bundle", path.display()), + })?; + let mut file = archive.by_name("metadata.json").map_err(|_| { + AlienError::new(ErrorData::ConfigurationError { + message: format!("bundle '{}' has no metadata.json", path.display()), + }) + })?; + let mut contents = String::new(); + file.read_to_string(&mut contents) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "could not read metadata.json from bundle".to_string(), + })?; + let value: Value = + serde_json::from_str(&contents) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "metadata.json is not valid JSON".to_string(), + })?; + let metadata: BundleMetadata = serde_json::from_value(value.clone()) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "metadata.json is missing required fields (name, version)".to_string(), + })?; + Ok((value, metadata)) +} + +fn api_url(base_url: &str, path: &str, workspace: &str) -> Result { + let mut url = reqwest::Url::parse(base_url) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "platform base URL is invalid".to_string(), + })?; + url.set_path(path); + url.query_pairs_mut().append_pair("workspace", workspace); + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::write::SimpleFileOptions; + + fn bundle_with_metadata(meta: &Value) -> Vec { + let mut buf = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + zip.start_file("metadata.json", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(serde_json::to_vec(meta).unwrap().as_slice()) + .unwrap(); + zip.finish().unwrap(); + } + buf + } + + #[test] + fn reads_metadata_from_bundle() { + let meta = serde_json::json!({ + "name": "postgres-operations", + "version": "1.0.0", + "tier": "mutating", + "binaries": { "amd64": "postgres-operations-linux-amd64" }, + "operations": [ { "name": "vacuum" } ] + }); + let bytes = bundle_with_metadata(&meta); + let (value, parsed) = + read_bundle_metadata(&bytes, &PathBuf::from("x.zip")).expect("valid bundle"); + assert_eq!(parsed.name, "postgres-operations"); + assert_eq!(parsed.version, "1.0.0"); + assert_eq!(parsed.tier.as_deref(), Some("mutating")); + // The full object is forwarded verbatim (operations[] preserved). + assert!(value.get("operations").is_some()); + } + + #[test] + fn rejects_bundle_without_metadata() { + // A ZIP with no metadata.json. + let mut buf = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + zip.start_file("binary", SimpleFileOptions::default()).unwrap(); + zip.write_all(b"#!/bin/sh\n").unwrap(); + zip.finish().unwrap(); + } + let err = read_bundle_metadata(&buf, &PathBuf::from("x.zip")).expect_err("must error"); + assert_eq!(err.code, "CONFIGURATION_ERROR"); + } + + #[test] + fn rejects_metadata_missing_required_fields() { + let meta = serde_json::json!({ "binaries": {} }); + let bytes = bundle_with_metadata(&meta); + let err = read_bundle_metadata(&bytes, &PathBuf::from("x.zip")).expect_err("must error"); + assert_eq!(err.code, "CONFIGURATION_ERROR"); + } +} diff --git a/crates/alien-cli/src/lib.rs b/crates/alien-cli/src/lib.rs index 7e96eafae..7cf6e345f 100644 --- a/crates/alien-cli/src/lib.rs +++ b/crates/alien-cli/src/lib.rs @@ -23,6 +23,8 @@ use crate::auth::normalize_workspace_name; #[cfg(feature = "platform")] use crate::commands::manager::{managers_task, ManagersArgs}; #[cfg(feature = "platform")] +use crate::commands::operations::{operations_task, OperationsArgs}; +#[cfg(feature = "platform")] use crate::commands::platform::{ link_task, login_task, logout_task, project_task, unlink_task, workspace_task, PlatformCommand, }; @@ -114,6 +116,8 @@ impl Cli { Some(Commands::Platform(PlatformCommand::Projects(args))) => args.json, #[cfg(feature = "platform")] Some(Commands::Managers(args)) => args.json, + #[cfg(feature = "platform")] + Some(Commands::Operations(args)) => args.json, _ => false, } } @@ -164,6 +168,11 @@ pub enum Commands { #[cfg(feature = "platform")] #[command(alias = "manager")] Managers(ManagersArgs), + + /// Manage operations plugins (publish custom bundles, list the catalog) + #[cfg(feature = "platform")] + #[command(alias = "operation")] + Operations(OperationsArgs), } #[derive(Parser, Debug, Clone)] @@ -1512,6 +1521,8 @@ pub async fn run_cli(cli: Cli) -> Result<()> { }, #[cfg(feature = "platform")] Some(Commands::Managers(args)) => managers_task(args, ctx).await?, + #[cfg(feature = "platform")] + Some(Commands::Operations(args)) => operations_task(args, ctx).await?, } Ok(())