hemmer-provider-sdk

Crates.iohemmer-provider-sdk
lib.rshemmer-provider-sdk
version0.3.1
created_at2025-11-29 17:17:01.329879+00
updated_at2026-01-18 04:53:58.42819+00
descriptionRust SDK for building Hemmer providers
homepage
repositoryhttps://github.com/hemmer-io/hemmer-provider-sdk
max_upload_size
id1957082
size345,827
John Turner (turner-hemmer)

documentation

README

Hemmer Provider SDK

Crates.io Documentation CI License

Rust SDK for building Hemmer providers

The Hemmer Provider SDK provides gRPC protocol types and server helpers for building providers that integrate with Hemmer, the next-generation Infrastructure as Code tool.

Status

🚧 Currently in active development

Features

  • Complete Provider Protocol: Full gRPC protocol for infrastructure management
  • Schema Support: Define resource and data source schemas with types, validation, and documentation
  • Pre-compiled Types: Committed Rust types (no build-time proto generation required)
  • Server Helpers: Easy provider startup with handshake protocol
  • Validation: Built-in support for provider, resource, and data source config validation
  • State Management: Support for state upgrades and resource imports

Installation

Add to your Cargo.toml:

[dependencies]
hemmer-provider-sdk = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

Quick Start

use hemmer_provider_sdk::{
    serve, ProviderService, ProviderError, PlanResult,
    schema::{ProviderSchema, Schema, Attribute, Diagnostic},
};

struct MyProvider;

#[hemmer_provider_sdk::async_trait]
impl ProviderService for MyProvider {
    fn schema(&self) -> ProviderSchema {
        ProviderSchema::new()
            .with_provider_config(
                Schema::v0()
                    .with_attribute("api_key", Attribute::required_string().sensitive())
            )
            .with_resource("mycloud_instance", Schema::v0()
                .with_attribute("name", Attribute::required_string())
                .with_attribute("size", Attribute::optional_string())
                .with_attribute("id", Attribute::computed_string())
            )
    }

    async fn configure(
        &self,
        config: serde_json::Value,
    ) -> Result<Vec<Diagnostic>, ProviderError> {
        // Initialize provider with credentials
        Ok(vec![])
    }

    async fn plan(
        &self,
        resource_type: &str,
        prior_state: Option<serde_json::Value>,
        proposed_state: serde_json::Value,
        config: serde_json::Value,
    ) -> Result<PlanResult, ProviderError> {
        Ok(PlanResult::no_change(proposed_state))
    }

    async fn create(
        &self,
        resource_type: &str,
        planned_state: serde_json::Value,
    ) -> Result<serde_json::Value, ProviderError> {
        // Create the resource and return its state
        Ok(planned_state)
    }

    async fn read(
        &self,
        resource_type: &str,
        current_state: serde_json::Value,
    ) -> Result<serde_json::Value, ProviderError> {
        // Read current state from the API
        Ok(current_state)
    }

    async fn update(
        &self,
        resource_type: &str,
        prior_state: serde_json::Value,
        planned_state: serde_json::Value,
    ) -> Result<serde_json::Value, ProviderError> {
        // Update the resource
        Ok(planned_state)
    }

    async fn delete(
        &self,
        resource_type: &str,
        current_state: serde_json::Value,
    ) -> Result<(), ProviderError> {
        // Delete the resource
        Ok(())
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    serve(MyProvider).await
}

Automatic Plan Diff Computation

The SDK provides automatic diff computation to simplify plan implementation. Instead of manually constructing AttributeChange instances, use PlanResult::from_diff():

async fn plan(
    &self,
    resource_type: &str,
    prior_state: Option<serde_json::Value>,
    proposed_state: serde_json::Value,
    config: serde_json::Value,
) -> Result<PlanResult, ProviderError> {
    // Automatically compute all changes between prior and proposed states
    Ok(PlanResult::from_diff(prior_state.as_ref(), &proposed_state))
}

The from_diff() method:

  • Automatically detects all changes between prior and proposed states
  • Supports nested objects with dot-notation paths (e.g., "metadata.labels.app")
  • Supports arrays with bracket notation (e.g., "tags[0]")
  • Returns no_change when states are identical
  • Marks all fields as added when creating new resources
  • Properly handles field additions, removals, and modifications

Manual Plan Construction

For advanced use cases where you need custom logic or want to mark specific changes as requiring replacement, you can still construct plans manually:

use hemmer_provider_sdk::{PlanResult, AttributeChange};

// Manual construction with requires_replace flag
let mut result = PlanResult::from_diff(prior_state.as_ref(), &proposed_state);

// Check if immutable field changed and mark as requiring replacement
if let Some(prior) = prior_state.as_ref() {
    if prior.get("ami") != proposed_state.get("ami") {
        result.requires_replace = true;
    }
}

Ok(result)

Provider Protocol

The SDK implements a complete provider protocol with the following RPCs:

RPC Purpose
GetMetadata Returns provider capabilities and resource/data source names
GetSchema Returns full schema for provider config, resources, and data sources
ValidateProviderConfig Validates provider configuration before use
Configure Configures provider with credentials and settings
Stop Gracefully shuts down the provider
ValidateResourceConfig Validates resource configuration before planning
UpgradeResourceState Migrates state from older schema versions
Plan Calculates required changes to reach desired state
Create Creates a new resource
Read Reads current state of a resource
Update Updates an existing resource
Delete Deletes a resource
ImportResourceState Imports existing infrastructure
ValidateDataSourceConfig Validates data source configuration
ReadDataSource Reads data from external sources

Schema Types

Define schemas for your resources using the builder pattern:

use hemmer_provider_sdk::schema::*;

let schema = Schema::v0()
    // Required string attribute
    .with_attribute("name", Attribute::required_string()
        .with_description("The name of the resource"))

    // Optional with default
    .with_attribute("region", Attribute::optional_string()
        .with_default(serde_json::json!("us-east-1")))

    // Computed (read-only) attribute
    .with_attribute("id", Attribute::computed_string())

    // Sensitive attribute (hidden in logs)
    .with_attribute("password", Attribute::required_string().sensitive())

    // Force replacement when changed
    .with_attribute("ami", Attribute::required_string().with_force_new())

    // Nested blocks
    .with_block("network", NestedBlock::list(
        Block::new()
            .with_attribute("subnet_id", Attribute::required_string())
            .with_attribute("security_groups",
                Attribute::new(AttributeType::list(AttributeType::String),
                              AttributeFlags::optional()))
    ));

Validation

The SDK provides built-in validation helpers to validate configuration values against schemas:

use hemmer_provider_sdk::{validate, is_valid, schema::Schema};
use serde_json::json;

let schema = Schema::v0()
    .with_attribute("name", Attribute::required_string())
    .with_attribute("count", Attribute::optional(AttributeType::Number));

let value = json!({
    "name": "my-resource",
    "count": 5
});

// Get detailed validation diagnostics
let diagnostics = validate(&schema, &value);
if diagnostics.is_empty() {
    println!("Configuration is valid!");
}

// Or use the simple boolean check
if is_valid(&schema, &value) {
    println!("Valid!");
}

Error Handling

The SDK provides a comprehensive ProviderError enum that maps to appropriate gRPC status codes:

use hemmer_provider_sdk::ProviderError;

// Resource not found (maps to NOT_FOUND)
return Err(ProviderError::NotFound("instance-123 not found".to_string()));

// Resource already exists (maps to ALREADY_EXISTS)
return Err(ProviderError::AlreadyExists("bucket my-bucket already exists".to_string()));

// Permission/auth errors (maps to PERMISSION_DENIED)
return Err(ProviderError::PermissionDenied("insufficient permissions to delete resource".to_string()));

// Rate limiting or quota errors (maps to RESOURCE_EXHAUSTED)
return Err(ProviderError::ResourceExhausted("API rate limit exceeded".to_string()));

// Temporary service issues (maps to UNAVAILABLE)
return Err(ProviderError::Unavailable("service temporarily unavailable".to_string()));

// Timeout errors (maps to DEADLINE_EXCEEDED)
return Err(ProviderError::DeadlineExceeded("operation timed out after 30s".to_string()));

// State precondition failures (maps to FAILED_PRECONDITION)
return Err(ProviderError::FailedPrecondition("resource must be stopped before deletion".to_string()));

// Unimplemented operations (maps to UNIMPLEMENTED)
return Err(ProviderError::Unimplemented("import not supported for this resource type".to_string()));

// Validation errors (maps to INVALID_ARGUMENT)
return Err(ProviderError::Validation("invalid instance size".to_string()));

// Configuration errors (maps to FAILED_PRECONDITION)
return Err(ProviderError::Configuration("missing required API key".to_string()));

Using the appropriate error variant enables:

  • Better UX: Users see meaningful error messages
  • Retry Logic: Clients can retry on Unavailable but not NotFound
  • Debugging: Error codes help identify root causes quickly

Testing

The SDK includes a test harness for provider implementations:

use hemmer_provider_sdk::{ProviderTester, ProviderService};
use serde_json::json;

#[tokio::test]
async fn test_resource_lifecycle() {
    let provider = MyProvider::new();
    let tester = ProviderTester::new(provider);

    // Test complete CRUD lifecycle
    let final_state = tester
        .lifecycle_crud(
            "mycloud_instance",
            json!({"name": "test"}),           // create config
            json!({"name": "test-updated"}),   // update config
        )
        .await
        .expect("lifecycle should succeed");

    // Or test individual operations with assertions
    let plan = tester
        .plan("mycloud_instance", None, json!({"name": "test"}))
        .await
        .unwrap();

    tester.assert_plan_creates(&plan);
}

Handshake Protocol

When a provider starts via serve(), it outputs a handshake string to stdout:

HEMMER_PROVIDER|1|127.0.0.1:50051

Format: HEMMER_PROVIDER|<protocol_version>|<address>

This allows Hemmer to spawn the provider as a subprocess and connect via gRPC.

Protocol Versioning

The SDK implements protocol version negotiation to ensure compatibility between Hemmer and providers built with different SDK versions.

Version Constants

use hemmer_provider_sdk::{PROTOCOL_VERSION, MIN_PROTOCOL_VERSION, check_protocol_version};

// Current protocol version (incremented for breaking changes)
assert_eq!(PROTOCOL_VERSION, 1);

// Minimum supported version for backwards compatibility
assert_eq!(MIN_PROTOCOL_VERSION, 1);

Version Negotiation

During the GetSchema RPC, Hemmer sends its protocol version, and the provider validates compatibility:

// Automatically handled by the SDK
// Providers reject clients with versions below MIN_PROTOCOL_VERSION
check_protocol_version(client_version)?;

Versioning Strategy

  • Patch (0.1.x): Bug fixes, no protocol changes
  • Minor (0.x.0): Additive changes (new optional fields), backwards compatible
  • Major (x.0.0): Breaking changes, increment PROTOCOL_VERSION

When PROTOCOL_VERSION is incremented, consider whether older clients should still be supported by setting MIN_PROTOCOL_VERSION appropriately.

Contributing

Quick Setup

# Clone and setup development environment
git clone https://github.com/hemmer-io/hemmer-provider-sdk
cd hemmer-provider-sdk
./scripts/setup.sh

The setup script will:

  • Install git pre-commit hooks
  • Verify your Rust toolchain
  • Run an initial build and test

Development Workflow

# Build
cargo build

# Run tests
cargo test

# Run linter
cargo clippy --all-targets -- -D warnings

# Format code
cargo fmt --all

# View documentation
cargo doc --no-deps --open

Regenerating Proto Types

Proto types are pre-compiled and committed. To regenerate after changing proto/provider.proto:

# Requires protoc to be installed
cargo build --features regenerate-proto

Code Style

  • Follow standard Rust conventions (rustfmt, clippy)
  • Document public APIs with doc comments
  • Write tests for new functionality
  • Keep commits focused and atomic

Pull Request Process

  1. Create a feature branch from main
  2. Make your changes with tests
  3. Ensure all checks pass (cargo test, cargo clippy, cargo fmt --check)
  4. Submit a PR using the template
  5. Address review feedback

Related Projects

License

Licensed under the Apache License, Version 2.0. See LICENSE for details.

Commit count: 42

cargo fmt