Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ tests/output
# Ignore Rust target directory
target/

# Ignore worktrees
worktrees/

# AI things
.claude
CLAUDE.md
141 changes: 138 additions & 3 deletions crates/wash/src/cli/oci.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::{collections::HashMap, path::PathBuf};

use anyhow::Context as _;
use clap::{Args, Subcommand};
Expand All @@ -11,6 +11,15 @@ use crate::{
runtime::bindings::plugin::wasmcloud::wash::types::HookType,
};

/// Parse annotation in key=value format
fn parse_annotation(s: &str) -> Result<(String, String), String> {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() != 2 || parts[0].is_empty() {
return Err("Annotation must be in key=value format".to_string());
}
Ok((parts[0].to_string(), parts[1].to_string()))
}

#[derive(Subcommand, Debug, Clone)]
pub enum OciCommand {
Pull(PullCommand),
Expand Down Expand Up @@ -85,6 +94,14 @@ pub struct PushCommand {
/// The path to the component to push
#[clap(name = "component_path")]
component_path: PathBuf,
/// Add an OCI annotation to the image manifest (can be specified multiple times)
///
/// Annotations from CLI flags will be merged with annotations from the wash config file.
/// CLI annotations take precedence if the same key exists in both.
///
/// Example: --annotation "org.opencontainers.image.description=My component"
#[clap(long = "annotation", value_parser = parse_annotation)]
annotations: Vec<(String, String)>,
}

impl PushCommand {
Expand All @@ -95,16 +112,134 @@ impl PushCommand {
.await
.context("failed to read component file")?;

// Build annotations from explicit annotations and config file annotations
let mut all_annotations = HashMap::new();

// First, load annotations from config file if available
if let Ok(config) = ctx.ensure_config(None).await
&& let Some(oci_config) = config.oci
{
for (key, value) in oci_config.annotations {
all_annotations.insert(key, value);
}
}

// Then add explicit CLI annotations (these override config annotations)
for (key, value) in &self.annotations {
all_annotations.insert(key.clone(), value.clone());
}

let oci_config = OciConfig::new_with_cache(ctx.cache_dir().join(OCI_CACHE_DIR));

push_component(&self.reference, &component, oci_config).await?;
let digest = push_component(
&self.reference,
&component,
oci_config,
if all_annotations.is_empty() {
None
} else {
Some(all_annotations)
},
)
.await?;

Ok(CommandOutput::ok(
"OCI command executed successfully.".to_string(),
format!("Successfully pushed component\ndigest: {}", digest),
Some(serde_json::json!({
"message": "OCI command executed successfully.",
"digest": digest,
"success": true,
})),
))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_parse_annotation_valid() {
// Test valid key=value format
let result = parse_annotation("key=value");
assert!(result.is_ok());
let (key, value) = result.unwrap();
assert_eq!(key, "key");
assert_eq!(value, "value");
}

#[test]
fn test_parse_annotation_with_equals_in_value() {
// Test key=value where value contains equals sign
let result = parse_annotation("url=http://example.com/path?param=value");
assert!(result.is_ok());
let (key, value) = result.unwrap();
assert_eq!(key, "url");
assert_eq!(value, "http://example.com/path?param=value");
}

#[test]
fn test_parse_annotation_opencontainer_format() {
// Test OpenContainer annotation format
let result = parse_annotation("org.opencontainers.image.description=A test component");
assert!(result.is_ok());
let (key, value) = result.unwrap();
assert_eq!(key, "org.opencontainers.image.description");
assert_eq!(value, "A test component");
}

#[test]
fn test_parse_annotation_empty_value() {
// Test annotation with empty value
let result = parse_annotation("key=");
assert!(result.is_ok());
let (key, value) = result.unwrap();
assert_eq!(key, "key");
assert_eq!(value, "");
}

#[test]
fn test_parse_annotation_invalid_format() {
// Test invalid format (no equals sign)
let result = parse_annotation("just-a-key");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Annotation must be in key=value format"
);
}

#[test]
fn test_parse_annotation_only_equals() {
// Test invalid format (only equals sign)
let result = parse_annotation("=");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Annotation must be in key=value format"
);
}

#[test]
fn test_annotation_collection_and_conversion() {
// Test that multiple annotations can be collected and converted properly
let annotations = vec![
parse_annotation("key1=value1").unwrap(),
parse_annotation("key2=value2").unwrap(),
parse_annotation("org.opencontainers.image.description=A test").unwrap(),
];

let mut annotation_map = HashMap::new();
for (key, value) in annotations {
annotation_map.insert(key, value);
}

assert_eq!(annotation_map.len(), 3);
assert_eq!(annotation_map.get("key1"), Some(&"value1".to_string()));
assert_eq!(annotation_map.get("key2"), Some(&"value2".to_string()));
assert_eq!(
annotation_map.get("org.opencontainers.image.description"),
Some(&"A test".to_string())
);
}
}
153 changes: 152 additions & 1 deletion crates/wash/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
//! wash configuration, including loading, saving, and merging configurations
//! with explicit defaults.

use std::path::{Path, PathBuf};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use figment::{
Expand All @@ -23,6 +26,36 @@ use crate::{

pub const PROJECT_CONFIG_DIR: &str = ".wash";

/// OCI configuration for registry operations
///
/// This configuration section allows you to set default OCI annotations that will be
/// applied to all `wash oci push` operations. CLI annotations using `--annotation` will
/// override config annotations if they use the same key.
///
/// # Example configuration
///
/// ```json
/// {
/// "oci": {
/// "annotations": {
/// "org.opencontainers.image.source": "https://github.com/myuser/myproject",
/// "org.opencontainers.image.description": "My WebAssembly component",
/// "org.opencontainers.image.version": "1.0.0",
/// "custom.annotation": "custom-value"
/// }
/// }
/// }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OciConfig {
/// Default annotations to apply to all OCI pushes
///
/// These annotations will be merged with any `--annotation` flags passed to `wash oci push`.
/// CLI annotations take precedence over config annotations if the same key is used.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
}

/// Main wash configuration structure with hierarchical merging support and explicit defaults
///
/// The "global" [Config] is stored under the user's XDG_CONFIG_HOME directory
Expand All @@ -42,6 +75,10 @@ pub struct Config {
/// WIT dependency management configuration (default: empty/optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub wit: Option<WitConfig>,

/// OCI registry configuration (default: empty/optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub oci: Option<OciConfig>,
// TODO(#15): Support dev config which can be overridden in local project config
// e.g. for runtime config, http ports, etc
}
Expand Down Expand Up @@ -269,3 +306,117 @@ pub async fn generate_default_config(path: &Path, force: bool) -> Result<()> {
info!(config_path = %path.display(), "Generated default configuration");
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;

#[test]
fn test_oci_config_serialization() {
let mut annotations = HashMap::new();
annotations.insert(
"org.opencontainers.image.description".to_string(),
"Test component".to_string(),
);
annotations.insert(
"org.opencontainers.image.version".to_string(),
"1.0.0".to_string(),
);

let oci_config = OciConfig { annotations };

let json = serde_json::to_string_pretty(&oci_config).unwrap();
let deserialized: OciConfig = serde_json::from_str(&json).unwrap();

assert_eq!(deserialized.annotations.len(), 2);
assert_eq!(
deserialized
.annotations
.get("org.opencontainers.image.description"),
Some(&"Test component".to_string())
);
assert_eq!(
deserialized
.annotations
.get("org.opencontainers.image.version"),
Some(&"1.0.0".to_string())
);
}

#[test]
fn test_oci_config_empty_annotations() {
let oci_config = OciConfig::default();

let json = serde_json::to_string_pretty(&oci_config).unwrap();
// Empty annotations should not appear in JSON due to skip_serializing_if
assert_eq!(json, "{}");

let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
assert!(deserialized.annotations.is_empty());
}

#[test]
fn test_config_with_oci_section() {
let mut annotations = HashMap::new();
annotations.insert("custom.key".to_string(), "custom.value".to_string());

let config = Config {
oci: Some(OciConfig { annotations }),
..Default::default()
};

let json = serde_json::to_string_pretty(&config).unwrap();
let deserialized: Config = serde_json::from_str(&json).unwrap();

assert!(deserialized.oci.is_some());
let oci_config = deserialized.oci.unwrap();
assert_eq!(oci_config.annotations.len(), 1);
assert_eq!(
oci_config.annotations.get("custom.key"),
Some(&"custom.value".to_string())
);
}

#[tokio::test]
async fn test_config_save_and_load_with_oci() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json");

// Create a config with OCI annotations
let mut annotations = HashMap::new();
annotations.insert(
"org.opencontainers.image.source".to_string(),
"https://github.com/test/repo".to_string(),
);
annotations.insert("custom.annotation".to_string(), "test-value".to_string());

let original_config = Config {
oci: Some(OciConfig { annotations }),
..Default::default()
};

// Save config
save_config(&original_config, &config_path).await.unwrap();
assert!(config_path.exists());

// Load config
let loaded_config = load_config(&config_path, None, None::<Config>).unwrap();

// Verify OCI config was preserved
assert!(loaded_config.oci.is_some());
let oci_config = loaded_config.oci.unwrap();
assert_eq!(oci_config.annotations.len(), 2);
assert_eq!(
oci_config
.annotations
.get("org.opencontainers.image.source"),
Some(&"https://github.com/test/repo".to_string())
);
assert_eq!(
oci_config.annotations.get("custom.annotation"),
Some(&"test-value".to_string())
);
}
}
Loading
Loading