Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/alien-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions crates/alien-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
305 changes: 305 additions & 0 deletions crates/alien-cli/src/commands/operations.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[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?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 API keys fail workspace resolution

When --api-key or ALIEN_API_KEY is used, operations_task unconditionally calls resolve_workspace_with_bootstrap, which rejects platform API keys because they are already workspace-scoped. Both operations commands therefore exit before sending a request.

Knowledge Base Used: Developer CLI and Deploy CLI

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-cli/src/commands/operations.rs
Line: 84

Comment:
**API keys fail workspace resolution**

When `--api-key` or `ALIEN_API_KEY` is used, `operations_task` unconditionally calls `resolve_workspace_with_bootstrap`, which rejects platform API keys because they are already workspace-scoped. Both operations commands therefore exit before sending a request.

**Knowledge Base Used:** [Developer CLI and Deploy CLI](https://app.greptile.com/alien/-/custom-context/knowledge-base/alienplatform/alien/-/docs/developer-cli.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

Comment on lines +83 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Standalone mode targets manager API

When ALIEN_MANAGER_URL is set, run_cli passes a standalone context whose authenticated base URL is the manager to this platform-only command. Publish and list then send /v1/operations/plugins?workspace=default to the standalone manager instead of the platform API, causing the commands to fail.

Knowledge Base Used: Developer CLI and Deploy CLI

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-cli/src/commands/operations.rs
Line: 83-84

Comment:
**Standalone mode targets manager API**

When `ALIEN_MANAGER_URL` is set, `run_cli` passes a standalone context whose authenticated base URL is the manager to this platform-only command. Publish and list then send `/v1/operations/plugins?workspace=default` to the standalone manager instead of the platform API, causing the commands to fail.

**Knowledge Base Used:** [Developer CLI and Deploy CLI](https://app.greptile.com/alien/-/custom-context/knowledge-base/alienplatform/alien/-/docs/developer-cli.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

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());
Comment on lines +179 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 JSON decoding failures look successful

When the platform returns a successful status with an empty, malformed, or unexpectedly shaped JSON body, unwrap_or(Value::Null) discards the decoding error. List then reports that no plugins are available, while publish JSON output prints null and exits successfully, misleading users and automation.

Knowledge Base Used: Developer CLI and Deploy CLI

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/alien-cli/src/commands/operations.rs
Line: 179-181

Comment:
**JSON decoding failures look successful**

When the platform returns a successful status with an empty, malformed, or unexpectedly shaped JSON body, `unwrap_or(Value::Null)` discards the decoding error. List then reports that no plugins are available, while publish JSON output prints `null` and exits successfully, misleading users and automation.

**Knowledge Base Used:** [Developer CLI and Deploy CLI](https://app.greptile.com/alien/-/custom-context/knowledge-base/alienplatform/alien/-/docs/developer-cli.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

} 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<reqwest::Url> {
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<u8> {
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");
}
}
11 changes: 11 additions & 0 deletions crates/alien-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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(())
Expand Down
Loading