Skip to content

[subtask] [Subtask 3/5] Provisioning Controller: Implement manifest-driven component loading #541

@github-actions

Description

@github-actions

Parent Issue: #307

Objective

Implement the provisioning controller that reads a manifest file, validates it, fetches components, verifies integrity, and materializes policies during startup.

Context

This is the core of headless deployment mode. The ProvisioningController orchestrates the declarative startup sequence, ensuring all components and policies are provisioned before the MCP server begins accepting requests. This replaces the interactive "load component at runtime" workflow with a fail-fast, auditable bootstrap process.

Implementation Details

1. Create Manifest Parser

File: crates/wassette/src/manifest.rs

Add parsing function:

use anyhow::{Context, Result};
use std::path::Path;
use std::fs;

impl ProvisioningManifest {
    /// Parse manifest from YAML or TOML file
    pub fn from_file(path: &Path) -> Result(Self) {
        let content = fs::read_to_string(path)
            .with_context(|| format!("Failed to read manifest: {}", path.display()))?;
        
        // Try YAML first, then TOML
        if path.extension().map_or(false, |ext| ext == "yaml" || ext == "yml") {
            serde_yaml::from_str(&content)
                .with_context(|| format!("Failed to parse YAML manifest: {}", path.display()))
        } else if path.extension().map_or(false, |ext| ext == "toml") {
            toml::from_str(&content)
                .with_context(|| format!("Failed to parse TOML manifest: {}", path.display()))
        } else {
            // Auto-detect: try YAML first, fall back to TOML
            serde_yaml::from_str(&content)
                .or_else(|_| toml::from_str(&content))
                .with_context(|| format!("Failed to parse manifest as YAML or TOML: {}", path.display()))
        }
    }

    /// Validate manifest for consistency and completeness
    pub fn validate(&self) -> Result<()> {
        // Check version
        if self.version != 1 {
            anyhow::bail!("Unsupported manifest version: {}. Expected version 1.", self.version);
        }

        // Check for duplicate URIs
        let mut seen_uris = std::collections::HashSet::new();
        for component in &self.components {
            if !seen_uris.insert(&component.uri) {
                anyhow::bail!("Duplicate component URI in manifest: {}", component.uri);
            }
        }

        // Validate permission configs
        for (idx, component) in self.components.iter().enumerate() {
            match &component.permissions {
                PermissionConfig::File { policy } => {
                    if !policy.exists() {
                        anyhow::bail!(
                            "Component {} references non-existent policy file: {}",
                            idx, policy.display()
                        );
                    }
                }
                PermissionConfig::Inline { .. } => {
                    // Inline permissions validated during policy synthesis
                }
            }

            // Validate retry policy if present
            if let Some(ref retry) = component.retry_policy {
                if retry.attempts == 0 {
                    anyhow::bail!("Component {} has invalid retry_policy.attempts: must be > 0", idx);
                }
            }
        }

        Ok(())
    }
}

Add dependencies to crates/wassette/Cargo.toml:

serde_yaml = "0.9"
toml = "0.8"

2. Create ProvisioningController

New File: crates/wassette/src/provisioning.rs

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! Provisioning controller for headless deployments

use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use tracing::{info, warn, debug};

use crate::{
    LifecycleManager,
    manifest::{ProvisioningManifest, ComponentEntry, PermissionConfig, BackoffStrategy},
    loader::ResourceUri,
};

pub struct ProvisioningController {
    manifest: ProvisioningManifest,
}

impl ProvisioningController {
    pub fn new(manifest: ProvisioningManifest) -> Self {
        Self { manifest }
    }

    /// Run the full provisioning sequence
    pub async fn provision(&self, lifecycle: &LifecycleManager) -> Result<()> {
        info!(
            component_count = self.manifest.components.len(),
            "Starting headless provisioning"
        );

        for (idx, component_entry) in self.manifest.components.iter().enumerate() {
            info!(
                uri = %component_entry.uri,
                component_index = idx + 1,
                total_components = self.manifest.components.len(),
                "Provisioning component"
            );

            self.provision_component(lifecycle, component_entry)
                .await
                .with_context(|| {
                    format!("Failed to provision component {}: {}", idx + 1, component_entry.uri)
                })?;
        }

        info!("Headless provisioning completed successfully");
        Ok(())
    }

    async fn provision_component(
        &self,
        lifecycle: &LifecycleManager,
        entry: &ComponentEntry,
    ) -> Result<()> {
        // Step 1: Fetch component with retry logic
        let component_data = self.fetch_with_retry(lifecycle, &entry.uri, entry.retry_policy.as_ref())
            .await
            .with_context(|| format!("Failed to fetch component: {}", entry.uri))?;

        // Step 2: Verify digest if provided
        if let Some(ref expected_digest) = entry.digest {
            self.verify_digest(&component_data, expected_digest)?;
        }

        // Step 3: Load component into lifecycle manager
        let component_id = lifecycle.load_component_from_bytes(&entry.uri, component_data)
            .await
            .with_context(|| format!("Failed to load component: {}", entry.uri))?;

        debug!(component_id = %component_id, "Component loaded");

        // Step 4: Materialize permissions
        self.materialize_permissions(lifecycle, &component_id, &entry.permissions)
            .await
            .with_context(|| format!("Failed to materialize permissions for component: {}", component_id))?;

        info!(component_id = %component_id, uri = %entry.uri, "Component provisioned successfully");
        Ok(())
    }

    async fn fetch_with_retry(
        &self,
        lifecycle: &LifecycleManager,
        uri: &str,
        retry_policy: Option<&crate::manifest::RetryPolicy>,
    ) -> Result(Vec<u8)> {
        let max_attempts = retry_policy.map(|p| p.attempts).unwrap_or(1);
        
        for attempt in 1..=max_attempts {
            match lifecycle.fetch_component_bytes(uri).await {
                Ok(data) => {
                    debug!(uri = %uri, attempt, "Component fetched successfully");
                    return Ok(data);
                }
                Err(e) if attempt < max_attempts => {
                    warn!(
                        uri = %uri,
                        attempt,
                        max_attempts,
                        error = %e,
                        "Component fetch failed, retrying..."
                    );

                    if let Some(ref policy) = retry_policy {
                        let delay_ms = match &policy.backoff {
                            BackoffStrategy::Exponential { base_ms } => {
                                base_ms * 2u64.pow(attempt - 1)
                            }
                            BackoffStrategy::Linear { increment_ms } => {
                                increment_ms * (attempt as u64)
                            }
                        };
                        tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
                    }
                }
                Err(e) => {
                    return Err(e).with_context(|| {
                        format!("Failed to fetch component after {} attempts", max_attempts)
                    });
                }
            }
        }
        
        unreachable!()
    }

    fn verify_digest(&self, data: &[u8], expected: &str) -> Result<()> {
        use sha2::{Sha256, Digest};

        // Parse expected format: "sha256:hexstring"
        let expected = expected.strip_prefix("sha256:")
            .ok_or_else(|| anyhow::anyhow!("Digest must start with 'sha256:'"))?;

        let mut hasher = Sha256::new();
        hasher.update(data);
        let actual = format!("{:x}", hasher.finalize());

        if actual != expected {
            anyhow::bail!(
                "Digest mismatch: expected sha256:{}, got sha256:{}",
                expected,
                actual
            );
        }

        debug!("Digest verification passed");
        Ok(())
    }

    async fn materialize_permissions(
        &self,
        lifecycle: &LifecycleManager,
        component_id: &str,
        permission_config: &PermissionConfig,
    ) -> Result<()> {
        match permission_config {
            PermissionConfig::File { policy } => {
                // Copy policy file to component directory
                lifecycle.install_policy_file(component_id, policy)
                    .await
                    .with_context(|| format!("Failed to install policy file: {}", policy.display()))?;
                debug!(component_id, policy_file = %policy.display(), "Policy file installed");
            }
            PermissionConfig::Inline { inline } => {
                // Synthesize policy from inline permissions
                lifecycle.synthesize_policy(component_id, inline)
                    .await
                    .with_context(|| "Failed to synthesize inline policy")?;
                debug!(component_id, "Inline policy synthesized");
            }
        }
        Ok(())
    }
}

Add to crates/wassette/Cargo.toml:

sha2 = "0.10"
tokio = { version = "1", features = ["time"] }

3. Add Provisioning Methods to LifecycleManager

File: crates/wassette/src/lib.rs

Add helper methods that the provisioning controller needs:

impl LifecycleManager {
    /// Fetch component bytes from URI (internal helper for provisioning)
    pub(crate) async fn fetch_component_bytes(&self, uri: &str) -> Result(Vec<u8)> {
        // Use existing loader infrastructure
        let resource = crate::loader::load_resource(
            uri,
            &self.http_client,
            &self.oci_client,
        ).await?;
        
        Ok(resource.bytes().to_vec())
    }

    /// Load component from bytes with explicit URI
    pub(crate) async fn load_component_from_bytes(
        &self,
        uri: &str,
        bytes: Vec(u8),
    ) -> Result(String) {
        // Leverage existing load_component logic but from bytes
        // This will require refactoring load_component to accept either URI or bytes
        todo!("Integrate with existing load_component infrastructure")
    }

    /// Install a policy file for a component
    pub(crate) async fn install_policy_file(
        &self,
        component_id: &str,
        policy_path: &Path,
    ) -> Result<()> {
        // Copy policy file to component directory as {component_id}.policy.yaml
        let dest = self.config.component_dir()
            .join(format!("{}.policy.yaml", component_id));
        
        std::fs::copy(policy_path, &dest)
            .with_context(|| format!("Failed to copy policy file to {}", dest.display()))?;
        
        // Reload policy into policy manager
        self.policy_manager.reload_policy(component_id).await?;
        
        Ok(())
    }

    /// Synthesize policy from inline permissions
    pub(crate) async fn synthesize_policy(
        &self,
        component_id: &str,
        inline_permissions: &crate::manifest::InlinePermissions,
    ) -> Result<()> {
        // Convert inline permissions to policy YAML format
        // Write to {component_id}.policy.yaml
        // Seed secrets manager with environment variables
        todo!("Implement inline policy synthesis")
    }
}

4. Integrate into Startup Sequence

File: crates/wassette/src/lib.rs

Update the LifecycleManager initialization to run provisioning when in headless mode:

// After building LifecycleManager
if config.profile() == &DeploymentProfile::Headless {
    if let Some(manifest_path) = config.manifest_path() {
        info!("Running headless provisioning from manifest: {}", manifest_path.display());
        
        let manifest = ProvisioningManifest::from_file(manifest_path)?;
        manifest.validate()?;
        
        let controller = crate::provisioning::ProvisioningController::new(manifest);
        controller.provision(&lifecycle_manager).await
            .context("Headless provisioning failed")?;
    } else {
        anyhow::bail!("Headless profile requires manifest path");
    }
}

5. Add Module Declaration

File: crates/wassette/src/lib.rs

mod provisioning;

Acceptance Criteria

  • Manifest can be parsed from YAML and TOML files
  • Manifest validation catches common errors (duplicate URIs, missing files, invalid values)
  • Component fetching works with existing loader infrastructure
  • Retry logic honors manifest retry_policy configuration
  • SHA-256 digest verification works correctly
  • Policy files can be copied to component directory
  • Provisioning controller runs during headless startup
  • All errors provide actionable context messages
  • cargo build succeeds
  • cargo clippy passes with no warnings

Testing Strategy

Unit Tests

Add to crates/wassette/src/manifest.rs:

#[test]
fn test_validate_duplicate_uris() {
    let manifest = ProvisioningManifest { /* duplicate URIs */ };
    assert!(manifest.validate().is_err());
}

#[test]
fn test_validate_missing_policy_file() {
    let manifest = ProvisioningManifest { /* non-existent policy */ };
    assert!(manifest.validate().is_err());
}

Add to crates/wassette/src/provisioning.rs:

#[test]
fn test_digest_verification() {
    let data = b"test data";
    let correct_digest = "sha256:916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9";
    assert!(verify_digest(data, correct_digest).is_ok());
    
    let wrong_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
    assert!(verify_digest(data, wrong_digest).is_err());
}

Integration Test

Create tests/headless_provisioning.rs:

#[tokio::test]
async fn test_headless_provisioning_flow() {
    // Create test manifest
    // Run provisioning
    // Verify components loaded
    // Verify policies installed
}

Dependencies

  • Subtask 1/5: Requires manifest schema types
  • Subtask 2/5: Requires CLI integration for triggering

References

AI generated by Plan for #307

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions